Kostenarten und geschützte Standkosten umsetzen

This commit is contained in:
gitea_admin committed 2026-09-20 12:16:31 +02:00
1 parent 313475afec
commit 3a0cb81a97
7 files changed
+200 -17

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
BEGIN;
CREATE TABLE market_cost_type (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO market_cost_type (code, name, is_system)
VALUES ('stand_fee', 'Standkosten', TRUE);
ALTER TABLE market_occurrence_cost_item
ADD COLUMN cost_type_id BIGINT REFERENCES market_cost_type(id) ON DELETE RESTRICT;
INSERT INTO market_cost_type (code, name)
SELECT DISTINCT
'legacy_' || md5(trim(cost_type)),
trim(cost_type)
FROM market_occurrence_cost_item
WHERE trim(cost_type) <> ''
AND lower(trim(cost_type)) <> 'standkosten'
ON CONFLICT (name) DO NOTHING;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.name = CASE
WHEN lower(trim(item.cost_type)) = 'standkosten' THEN 'Standkosten'
ELSE trim(item.cost_type)
END;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.code = 'stand_fee'
AND item.id = (
SELECT MIN(first_item.id)
FROM market_occurrence_cost_item first_item
WHERE first_item.occurrence_id = item.occurrence_id
);
INSERT INTO market_occurrence_cost_item (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate)
SELECT occurrence.id, type.id, NULL, 0.00, 'taxable', 8.10
FROM market_occurrence occurrence
JOIN market_cost_type type ON type.code = 'stand_fee'
WHERE NOT EXISTS (
SELECT 1 FROM market_occurrence_cost_item item WHERE item.occurrence_id = occurrence.id
);
ALTER TABLE market_occurrence_cost_item
ALTER COLUMN cost_type_id SET NOT NULL;
ALTER TABLE market_occurrence_cost_item
DROP COLUMN cost_type;
CREATE INDEX idx_market_occurrence_cost_item_type
ON market_occurrence_cost_item(cost_type_id);
COMMIT;
@@ -186,12 +186,16 @@ Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`, Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`,
`contact_email` und `contact_phone` ändern. `contact_email` und `contact_phone` ändern.
- `market_management.create_cost_item` - `market_management.create_cost_item`
- erforderlich: `occurrence_id`, `cost_type`, `gross_amount`, `tax_status` - erforderlich: `occurrence_id`, `cost_type_id`, `gross_amount`, `tax_status`
- `tax_status`: `tax_free` oder `taxable` - `tax_status`: `tax_free` oder `taxable`
- `market_management.update_cost_item` - `market_management.update_cost_item`
- erforderlich: `cost_item_id` - erforderlich: `cost_item_id`
- `market_management.delete_cost_item` - `market_management.delete_cost_item`
- erforderlich: `cost_item_id` - erforderlich: `cost_item_id`
- Die erste Kostenposition jeder Durchführung ist `Standkosten` (`stand_fee`),
nicht löschbar und darf den Betrag `0.00` haben. Weitere Positionen sind löschbar.
- `market_management.list_cost_types` liest aktive Kostenarten;
`market_management.create_cost_type` ergänzt das Register.
## Beispiel: Markt anlegen ## Beispiel: Markt anlegen
@@ -161,6 +161,9 @@ Einzelne Kostenposition einer Durchführung.
Normativ: Normativ:
- jede Kostenposition gehört genau einer Durchführung - jede Kostenposition gehört genau einer Durchführung
- jede Durchführung besitzt als erste Position zwingend die Kostenart
`Standkosten` (`stand_fee`); deren Betrag darf `0.00` sein
- weitere Kostenarten werden im erweiterbaren Register `market_cost_type` geführt
- CHF ist die einzige Währung - CHF ist die einzige Währung
- Bruttobetrag wird erfasst - Bruttobetrag wird erfasst
- steuerfreie Positionen sind zulässig - steuerfreie Positionen sind zulässig
+11 -2
View File
@@ -82,6 +82,8 @@ function market_management_mcp_tools(): array
['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]], ['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]], ['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]], ['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]],
['name' => 'market_management.list_cost_types', 'description' => 'Aktive Kostenarten lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.create_cost_type', 'description' => 'Kostenart anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'code' => ['type' => 'string']]]],
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]], ['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]], ['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]],
['name' => 'market_management.get_occurrence', 'description' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]], ['name' => 'market_management.get_occurrence', 'description' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
@@ -91,8 +93,8 @@ function market_management_mcp_tools(): array
['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $dayProperties]], ['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']] + $dayProperties]], ['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']]]], ['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']]]],
['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type', 'gross_amount', 'tax_status'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'cost_type' => ['type' => 'string'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => 'number'], 'tax_status' => ['type' => 'string', 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => 'number']]]], ['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type_id', 'gross_amount', 'tax_status'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'cost_type_id' => ['type' => 'integer'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => 'number'], 'tax_status' => ['type' => 'string', 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => 'number']]]],
['name' => 'market_management.update_cost_item', 'description' => 'Kostenposition ändern', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer'], 'cost_type' => ['type' => 'string'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => ['number', 'null']], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null']]]]], ['name' => 'market_management.update_cost_item', 'description' => 'Kostenposition ändern', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer'], 'cost_type_id' => ['type' => 'integer'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => ['number', 'null']], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null']]]]],
['name' => 'market_management.delete_cost_item', 'description' => 'Kostenposition entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer']]]], ['name' => 'market_management.delete_cost_item', 'description' => 'Kostenposition entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer']]]],
]; ];
} }
@@ -103,6 +105,7 @@ function market_management_mcp_is_write_tool(string $tool): bool
'market_management.create_market', 'market_management.update_market', 'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer', 'market_management.create_market_organizer',
'market_management.create_municipality', 'market_management.create_municipality',
'market_management.create_cost_type',
'market_management.archive_market', 'market_management.reactivate_market', 'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence', 'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.archive_occurrence', 'market_management.reactivate_occurrence', 'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
@@ -282,6 +285,12 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a
$pdo, $pdo,
(string) ($arguments['name'] ?? '') (string) ($arguments['name'] ?? '')
), ),
'market_management.list_cost_types' => market_management_list_cost_types($pdo),
'market_management.create_cost_type' => market_management_create_cost_type(
$pdo,
(string) ($arguments['name'] ?? ''),
isset($arguments['code']) ? (string) $arguments['code'] : null
),
'market_management.get_overview' => [ 'market_management.get_overview' => [
'markets' => market_management_search_markets($pdo, $arguments), 'markets' => market_management_search_markets($pdo, $arguments),
'occurrences' => market_management_search_occurrences($pdo, $arguments), 'occurrences' => market_management_search_occurrences($pdo, $arguments),
+98 -14
View File
@@ -89,6 +89,60 @@ function market_management_create_municipality(PDO $pdo, string $name): array
return (array) $stmt->fetch(); return (array) $stmt->fetch();
} }
function market_management_list_cost_types(PDO $pdo): array
{
return $pdo->query(
'SELECT id, code, name, is_system, is_active
FROM market_cost_type
WHERE is_active = TRUE
ORDER BY is_system DESC, name ASC, id ASC'
)->fetchAll() ?: [];
}
function market_management_get_cost_type(PDO $pdo, int $costTypeId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, code, name, is_system, is_active
FROM market_cost_type WHERE id = :id AND is_active = TRUE'
);
$stmt->execute([':id' => $costTypeId]);
$type = $stmt->fetch();
return is_array($type) ? $type : null;
}
function market_management_get_cost_type_id(PDO $pdo, string $code): int
{
$stmt = $pdo->prepare('SELECT id FROM market_cost_type WHERE code = :code AND is_active = TRUE');
$stmt->execute([':code' => $code]);
$id = $stmt->fetchColumn();
if ($id === false) {
throw new RuntimeException('Standard-Kostenart fehlt.');
}
return (int) $id;
}
function market_management_create_cost_type(PDO $pdo, string $name, ?string $code = null): array
{
$name = trim($name);
$code = trim((string) ($code ?? ''));
if ($name === '') {
throw new InvalidArgumentException('Kostenartenname fehlt.');
}
if ($code === '') {
$code = strtolower(trim((string) preg_replace('/[^a-z0-9]+/i', '_', $name), '_'));
}
if ($code === '') {
throw new InvalidArgumentException('Code der Kostenart fehlt.');
}
$stmt = $pdo->prepare(
'INSERT INTO market_cost_type (code, name)
VALUES (:code, :name)
RETURNING id, code, name, is_system, is_active'
);
$stmt->execute([':code' => $code, ':name' => $name]);
return (array) $stmt->fetch();
}
function market_management_require_positive_id(mixed $value, string $field): int function market_management_require_positive_id(mixed $value, string $field): int
{ {
if (!is_int($value) && !ctype_digit((string) $value)) { if (!is_int($value) && !ctype_digit((string) $value)) {
@@ -724,6 +778,13 @@ function market_management_create_occurrence(PDO $pdo, array $input): array
'contact_email' => null, 'contact_email' => null,
'contact_phone' => null, 'contact_phone' => null,
]); ]);
market_management_create_cost_item($pdo, [
'occurrence_id' => (int) $occurrence['id'],
'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'),
'gross_amount' => 0,
'tax_status' => 'taxable',
'tax_rate' => 8.10,
]);
if ($startedTransaction) { if ($startedTransaction) {
$pdo->commit(); $pdo->commit();
} }
@@ -865,8 +926,14 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc
'last_name' => '', 'last_name' => '',
'party_name' => '', 'party_name' => '',
]], ]],
'cost_types' => market_management_list_cost_types($pdo),
]; ];
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC'); $costs = $pdo->prepare(
'SELECT item.*, type.code AS cost_type_code, type.name AS cost_type
FROM market_occurrence_cost_item item
INNER JOIN market_cost_type type ON type.id = item.cost_type_id
WHERE item.occurrence_id = :occurrence_id ORDER BY item.id ASC'
);
$costs->execute([':occurrence_id' => $occurrenceId]); $costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll(); $occurrence['cost_items'] = $costs->fetchAll();
$costSummary = $pdo->prepare( $costSummary = $pdo->prepare(
@@ -1195,35 +1262,51 @@ function market_management_set_occurrence_contact(PDO $pdo, array $input): array
function market_management_create_cost_item(PDO $pdo, array $input): array function market_management_create_cost_item(PDO $pdo, array $input): array
{ {
$occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id');
$costType = trim((string) ($input['cost_type'] ?? '')); $costTypeId = market_management_require_positive_id($input['cost_type_id'] ?? null, 'cost_type_id');
$grossAmount = market_management_optional_number($input, 'gross_amount'); $grossAmount = market_management_optional_number($input, 'gross_amount');
$taxStatus = trim((string) ($input['tax_status'] ?? '')); $taxStatus = trim((string) ($input['tax_status'] ?? ''));
$taxRate = market_management_optional_number($input, 'tax_rate') ?? '0.00'; $taxRate = market_management_optional_number($input, 'tax_rate') ?? '0.00';
if ($costType === '' || $grossAmount === null || !in_array($taxStatus, ['tax_free', 'taxable'], true)) { if ($grossAmount === null || !in_array($taxStatus, ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('cost_type, gross_amount und tax_status sind erforderlich.'); throw new InvalidArgumentException('cost_type_id, gross_amount und tax_status sind erforderlich.');
} }
if (($taxStatus === 'tax_free' && $taxRate !== '0.00') || ($taxStatus === 'taxable' && (float) $taxRate <= 0)) { if (($taxStatus === 'tax_free' && $taxRate !== '0.00') || ($taxStatus === 'taxable' && (float) $taxRate <= 0)) {
throw new InvalidArgumentException('tax_rate passt nicht zu tax_status.'); throw new InvalidArgumentException('tax_rate passt nicht zu tax_status.');
} }
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'INSERT INTO market_occurrence_cost_item 'INSERT INTO market_occurrence_cost_item
(occurrence_id, cost_type, description, gross_amount, tax_status, tax_rate) (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate)
VALUES (:occurrence_id, :cost_type, :description, :gross_amount, :tax_status, :tax_rate) VALUES (:occurrence_id, :cost_type_id, :description, :gross_amount, :tax_status, :tax_rate)
RETURNING *' RETURNING *'
); );
if (market_management_get_cost_type($pdo, $costTypeId) === null) {
throw new InvalidArgumentException('Kostenart nicht gefunden oder inaktiv.');
}
$stmt->execute([ $stmt->execute([
':occurrence_id' => $occurrenceId, ':occurrence_id' => $occurrenceId,
':cost_type' => $costType, ':cost_type_id' => $costTypeId,
':description' => market_management_optional_string($input, 'description'), ':description' => market_management_optional_string($input, 'description'),
':gross_amount' => $grossAmount, ':gross_amount' => $grossAmount,
':tax_status' => $taxStatus, ':tax_status' => $taxStatus,
':tax_rate' => $taxRate, ':tax_rate' => $taxRate,
]); ]);
return (array) $stmt->fetch(); $item = (array) $stmt->fetch();
$item['cost_type'] = market_management_get_cost_type($pdo, $costTypeId)['name'];
return $item;
} }
function market_management_delete_cost_item(PDO $pdo, int $costItemId): array function market_management_delete_cost_item(PDO $pdo, int $costItemId): array
{ {
$stmt = $pdo->prepare('SELECT id, occurrence_id FROM market_occurrence_cost_item WHERE id = :id');
$stmt->execute([':id' => $costItemId]);
$item = $stmt->fetch();
if (!is_array($item)) {
throw new RuntimeException('Kostenposition nicht gefunden.');
}
$first = $pdo->prepare('SELECT MIN(id) FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id');
$first->execute([':occurrence_id' => $item['occurrence_id']]);
if ((int) $first->fetchColumn() === $costItemId) {
throw new InvalidArgumentException('Die erste Standkostenposition darf nicht gelöscht werden.');
}
$stmt = $pdo->prepare('DELETE FROM market_occurrence_cost_item WHERE id = :id RETURNING id, occurrence_id'); $stmt = $pdo->prepare('DELETE FROM market_occurrence_cost_item WHERE id = :id RETURNING id, occurrence_id');
$stmt->execute([':id' => $costItemId]); $stmt->execute([':id' => $costItemId]);
$item = $stmt->fetch(); $item = $stmt->fetch();
@@ -1237,13 +1320,13 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
{ {
$sets = []; $sets = [];
$params = [':id' => $costItemId]; $params = [':id' => $costItemId];
if (array_key_exists('cost_type', $input)) { if (array_key_exists('cost_type_id', $input)) {
$costType = trim((string) $input['cost_type']); $costTypeId = market_management_require_positive_id($input['cost_type_id'], 'cost_type_id');
if ($costType === '') { if (market_management_get_cost_type($pdo, $costTypeId) === null) {
throw new InvalidArgumentException('cost_type darf nicht leer sein.'); throw new InvalidArgumentException('Kostenart nicht gefunden oder inaktiv.');
} }
$sets[] = 'cost_type = :cost_type'; $sets[] = 'cost_type_id = :cost_type_id';
$params[':cost_type'] = $costType; $params[':cost_type_id'] = $costTypeId;
} }
foreach (['description' => 'string', 'gross_amount' => 'number', 'tax_status' => 'string', 'tax_rate' => 'number'] as $field => $type) { foreach (['description' => 'string', 'gross_amount' => 'number', 'tax_status' => 'string', 'tax_rate' => 'number'] as $field => $type) {
if (!array_key_exists($field, $input)) { if (!array_key_exists($field, $input)) {
@@ -1269,5 +1352,6 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
if (!is_array($item)) { if (!is_array($item)) {
throw new RuntimeException('Kostenposition nicht gefunden.'); throw new RuntimeException('Kostenposition nicht gefunden.');
} }
$item['cost_type'] = market_management_get_cost_type($pdo, (int) $item['cost_type_id'])['name'];
return $item; return $item;
} }
+3
View File
@@ -1188,9 +1188,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " const marketCostItemObserver = new MutationObserver(() => enhanceMarketCostItems(contentRoot)); marketCostItemObserver.observe(contentRoot, { childList: true, subtree: true }); enhanceMarketCostItems(contentRoot);"; echo " const marketCostItemObserver = new MutationObserver(() => enhanceMarketCostItems(contentRoot)); marketCostItemObserver.observe(contentRoot, { childList: true, subtree: true }); enhanceMarketCostItems(contentRoot);";
echo " const normalizeMarketCostItems = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { const field = form.querySelector('[data-market-cost-items-editor]')?.parentElement; if (!field || field.dataset.marketCostItemsNormalized === 'true') { return; } const chapter = document.createElement('section'); chapter.className = 'sg-form-sections-card__chapter'; const title = document.createElement('h2'); title.className = 'sg-strong sg-form-sections-card__chapter-title'; title.textContent = 'Kostenpositionen'; chapter.appendChild(title); while (field.firstChild) { chapter.appendChild(field.firstChild); } field.replaceWith(chapter); form._marketCostItemsField = chapter; const render = form._marketCostItemRender; form._marketCostItemRender = () => { render(); form.querySelectorAll('[data-market-cost-item-save], [data-market-cost-item-delete], [data-market-cost-item-cancel]').forEach((button) => button.remove()); }; form._marketCostItemRender(); chapter.dataset.marketCostItemsNormalized = 'true'; }); }; const collectMarketCostItems = (form) => Array.from(form.querySelectorAll('[data-market-cost-item-row]')).map((row) => { const item = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); if (row.dataset.marketCostItemId) { item.id = Number(row.dataset.marketCostItemId); } return item; });"; echo " const normalizeMarketCostItems = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { const field = form.querySelector('[data-market-cost-items-editor]')?.parentElement; if (!field || field.dataset.marketCostItemsNormalized === 'true') { return; } const chapter = document.createElement('section'); chapter.className = 'sg-form-sections-card__chapter'; const title = document.createElement('h2'); title.className = 'sg-strong sg-form-sections-card__chapter-title'; title.textContent = 'Kostenpositionen'; chapter.appendChild(title); while (field.firstChild) { chapter.appendChild(field.firstChild); } field.replaceWith(chapter); form._marketCostItemsField = chapter; const render = form._marketCostItemRender; form._marketCostItemRender = () => { render(); form.querySelectorAll('[data-market-cost-item-save], [data-market-cost-item-delete], [data-market-cost-item-cancel]').forEach((button) => button.remove()); }; form._marketCostItemRender(); chapter.dataset.marketCostItemsNormalized = 'true'; }); }; const collectMarketCostItems = (form) => Array.from(form.querySelectorAll('[data-market-cost-item-row]')).map((row) => { const item = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); if (row.dataset.marketCostItemId) { item.id = Number(row.dataset.marketCostItemId); } return item; });";
echo " const marketCostItemsNormalizationObserver = new MutationObserver(() => normalizeMarketCostItems(contentRoot)); marketCostItemsNormalizationObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketCostItems(contentRoot);"; echo " const marketCostItemsNormalizationObserver = new MutationObserver(() => normalizeMarketCostItems(contentRoot)); marketCostItemsNormalizationObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketCostItems(contentRoot);";
echo " const enhanceMarketCostTypeControls = (root) => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const types = Array.isArray(payload?.occurrence?.reference_options?.cost_types) ? payload.occurrence.reference_options.cost_types : []; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation] [data-market-cost-item-row]').forEach((row, index) => { const old = row.querySelector('[data-market-cost-field=cost_type]'); if (old && old.tagName === 'INPUT') { const select = document.createElement('select'); select.className = 'sg-interaction-element sg-input-single-line sg-pulldown'; select.dataset.marketCostField = 'cost_type_id'; select.innerHTML = types.map((type) => '<option value=\"' + marketEscape(type.id) + '\">' + marketEscape(type.name) + '</option>').join('') + '<option value=\"__new__\">Neue Kostenart anlegen</option>'; const selected = types.find((type) => String(type.name) === String(old.value)); select.value = selected ? String(selected.id) : ''; old.replaceWith(select); } const select = row.querySelector('[data-market-cost-field=cost_type_id]'); if (!select || select.dataset.marketCostTypeBound === 'true') { return; } const syncNew = () => { row.querySelector('[data-market-new-cost-type]')?.remove(); if (select.value === '__new__') { row.insertAdjacentHTML('beforeend', '<label class=\"sg-labeled-input-row\" data-market-new-cost-type><span class=\"sg-label\">Neue Kostenart</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" data-market-cost-field=\"cost_type_name\" required></label>'); } }; select.addEventListener('change', syncNew); select.dataset.marketCostTypeBound = 'true'; syncNew(); if (index > 0 && !row.querySelector('[data-market-cost-item-remove]')) { row.insertAdjacentHTML('beforeend', '<button class=\"sg-interaction-element sg-button sg-button--warning\" type=\"button\" data-market-cost-item-remove>Löschen</button>'); } }); }; const marketCostTypeObserver = new MutationObserver(() => enhanceMarketCostTypeControls(contentRoot)); marketCostTypeObserver.observe(contentRoot, { childList: true, subtree: true }); enhanceMarketCostTypeControls(contentRoot);";
echo " if (!marketsBindingsInstalled) {"; echo " if (!marketsBindingsInstalled) {";
echo " marketsBindingsInstalled = true;"; echo " marketsBindingsInstalled = true;";
echo " document.addEventListener('click', async (event) => { const costAdd = event.target.closest('[data-market-cost-item-add]'); if (costAdd) { const form = costAdd.closest('[data-market-edit-form]'); if (form?._marketCostItems) { form._marketCostItems.push({ cost_type: '', description: '', gross_amount: '', tax_status: 'tax_free', tax_rate: '0.00' }); form._marketCostItemRender(); } return; } const costCancel = event.target.closest('[data-market-cost-item-cancel]'); if (costCancel) { const form = costCancel.closest('[data-market-edit-form]'); const row = costCancel.closest('[data-market-cost-item-row]'); const index = Array.from(form.querySelectorAll('[data-market-cost-item-row]')).indexOf(row); if (index >= 0) { form._marketCostItems.splice(index, 1); form._marketCostItemRender(); } return; } const costSave = event.target.closest('[data-market-cost-item-save]'); if (costSave) { const form = costSave.closest('[data-market-edit-form]'); const row = costSave.closest('[data-market-cost-item-row]'); const fields = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); const itemId = row.dataset.marketCostItemId; const status = form.querySelector('[data-market-cost-item-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: itemId ? 'update_cost_item' : 'create_cost_item', id: itemId || form.dataset.marketEditId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } return; } const costDelete = event.target.closest('[data-market-cost-item-delete]'); if (costDelete) { const form = costDelete.closest('[data-market-edit-form]'); const row = costDelete.closest('[data-market-cost-item-row]'); const itemId = row?.dataset.marketCostItemId || ''; if (!itemId || !window.confirm('Diese Kostenposition löschen?')) { return; } const status = form.querySelector('[data-market-cost-item-status]'); if (status) { status.textContent = 'Löschen läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete_cost_item', id: itemId, confirmation: 'DELETE' }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } return; } const dayAdd = event.target.closest('[data-market-day-add]'); if (dayAdd) { event.preventDefault(); addMarketDayDraft(); return; } const editButton = event.target.closest('[data-shared-editable-card-edit]'); if (editButton) { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); return; } const cancelButton = event.target.closest('[data-shared-editable-card-cancel]'); if (cancelButton) { if (cancelButton.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { window.setTimeout(discardMarketDayDraft, 0); } else { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); } } });"; echo " document.addEventListener('click', async (event) => { const costAdd = event.target.closest('[data-market-cost-item-add]'); if (costAdd) { const form = costAdd.closest('[data-market-edit-form]'); if (form?._marketCostItems) { form._marketCostItems.push({ cost_type: '', description: '', gross_amount: '', tax_status: 'tax_free', tax_rate: '0.00' }); form._marketCostItemRender(); } return; } const costCancel = event.target.closest('[data-market-cost-item-cancel]'); if (costCancel) { const form = costCancel.closest('[data-market-edit-form]'); const row = costCancel.closest('[data-market-cost-item-row]'); const index = Array.from(form.querySelectorAll('[data-market-cost-item-row]')).indexOf(row); if (index >= 0) { form._marketCostItems.splice(index, 1); form._marketCostItemRender(); } return; } const costSave = event.target.closest('[data-market-cost-item-save]'); if (costSave) { const form = costSave.closest('[data-market-edit-form]'); const row = costSave.closest('[data-market-cost-item-row]'); const fields = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); const itemId = row.dataset.marketCostItemId; const status = form.querySelector('[data-market-cost-item-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: itemId ? 'update_cost_item' : 'create_cost_item', id: itemId || form.dataset.marketEditId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } return; } const costDelete = event.target.closest('[data-market-cost-item-delete]'); if (costDelete) { const form = costDelete.closest('[data-market-edit-form]'); const row = costDelete.closest('[data-market-cost-item-row]'); const itemId = row?.dataset.marketCostItemId || ''; if (!itemId || !window.confirm('Diese Kostenposition löschen?')) { return; } const status = form.querySelector('[data-market-cost-item-status]'); if (status) { status.textContent = 'Löschen läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete_cost_item', id: itemId, confirmation: 'DELETE' }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } return; } const dayAdd = event.target.closest('[data-market-day-add]'); if (dayAdd) { event.preventDefault(); addMarketDayDraft(); return; } const editButton = event.target.closest('[data-shared-editable-card-edit]'); if (editButton) { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); return; } const cancelButton = event.target.closest('[data-shared-editable-card-cancel]'); if (cancelButton) { if (cancelButton.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { window.setTimeout(discardMarketDayDraft, 0); } else { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); } } });";
echo " document.addEventListener('click', (event) => { const costAdd = event.target.closest('[data-market-cost-item-add]'); if (!costAdd) { return; } window.setTimeout(() => { const form = costAdd.closest('[data-market-edit-form]'); const item = form?._marketCostItems?.at(-1); if (item && !item.id && item.cost_type === '') { item.cost_type = 'Standkosten'; item.gross_amount = '0.00'; item.tax_status = 'taxable'; item.tax_rate = '8.10'; form._marketCostItemRender(); } }, 0); });";
echo " document.addEventListener('click', (event) => { const remove = event.target.closest('[data-market-cost-item-remove]'); if (!remove) { return; } const form = remove.closest('[data-market-edit-form]'); const row = remove.closest('[data-market-cost-item-row]'); const index = Array.from(form.querySelectorAll('[data-market-cost-item-row]')).indexOf(row); if (index <= 0 || window.prompt('Zum Löschen bitte DELETE eingeben.') !== 'DELETE') { return; } form._marketCostItems.splice(index, 1); form._marketCostItemRender(); });";
echo " document.addEventListener('click', (event) => { const deleteDaySubmit = event.target.closest('[data-market-day-delete-submit]'); if (deleteDaySubmit) { void deleteMarketDay(deleteDaySubmit); return; } const createButton = event.target.closest('[data-market-occurrence-create]'); if (createButton) { event.preventDefault(); void openNewMarketDrawer(); return; } if (event.target.closest('[data-market-create-cancel]')) { closeMarketDrawer(); } });"; echo " document.addEventListener('click', (event) => { const deleteDaySubmit = event.target.closest('[data-market-day-delete-submit]'); if (deleteDaySubmit) { void deleteMarketDay(deleteDaySubmit); return; } const createButton = event.target.closest('[data-market-occurrence-create]'); if (createButton) { event.preventDefault(); void openNewMarketDrawer(); return; } if (event.target.closest('[data-market-create-cancel]')) { closeMarketDrawer(); } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-create-form]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-create-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create', fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const detailResponse = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(result.occurrence_id), { credentials: 'same-origin' }); const payload = await detailResponse.json(); if (!detailResponse.ok || !payload.ok) { throw new Error(payload.error || 'Die neue Durchführung konnte nicht geladen werden.'); } const drawer = getMarketDrawer(); const title = drawer?.querySelector('[data-market-drawer-title]'); const content = drawer?.querySelector('[data-market-drawer-content]'); if (drawer) { drawer.dataset.marketPayload = JSON.stringify(payload); } if (title) { title.textContent = marketDrawerTitle(payload); } if (content) { content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); syncMarketDayAddButton(content); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });"; echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-create-form]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-create-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create', fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const detailResponse = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(result.occurrence_id), { credentials: 'same-origin' }); const payload = await detailResponse.json(); if (!detailResponse.ok || !payload.ok) { throw new Error(payload.error || 'Die neue Durchführung konnte nicht geladen werden.'); } const drawer = getMarketDrawer(); const title = drawer?.querySelector('[data-market-drawer-title]'); const content = drawer?.querySelector('[data-market-drawer-content]'); if (drawer) { drawer.dataset.marketPayload = JSON.stringify(payload); } if (title) { title.textContent = marketDrawerTitle(payload); } if (content) { content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); syncMarketDayAddButton(content); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-edit-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create_day', id: form.dataset.marketOccurrenceId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });"; echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-edit-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create_day', id: form.dataset.marketOccurrenceId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });";
+17
View File
@@ -105,10 +105,27 @@ if ($requestMethod === 'POST') {
throw new InvalidArgumentException('Kostenpositionen sind ungültig.'); throw new InvalidArgumentException('Kostenpositionen sind ungültig.');
} }
unset($fields['cost_items']); unset($fields['cost_items']);
$keptCostItemIds = array_values(array_filter(array_map(
static fn (array $costItem): ?int => isset($costItem['id']) ? (int) $costItem['id'] : null,
array_filter($costItems, 'is_array')
)));
$existingCostItems = $pdo->prepare('SELECT id FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$existingCostItems->execute([':occurrence_id' => $id]);
foreach ($existingCostItems->fetchAll(PDO::FETCH_COLUMN) as $existingCostItemId) {
if (!in_array((int) $existingCostItemId, $keptCostItemIds, true)) {
market_management_delete_cost_item($pdo, (int) $existingCostItemId);
}
}
foreach ($costItems as $costItem) { foreach ($costItems as $costItem) {
if (!is_array($costItem)) { if (!is_array($costItem)) {
throw new InvalidArgumentException('Kostenposition ist ungültig.'); throw new InvalidArgumentException('Kostenposition ist ungültig.');
} }
if (($costItem['cost_type_id'] ?? null) === '__new__') {
$newCostTypeName = trim((string) ($costItem['cost_type_name'] ?? ''));
$newCostType = market_management_create_cost_type($pdo, $newCostTypeName);
$costItem['cost_type_id'] = (int) $newCostType['id'];
}
unset($costItem['cost_type_name']);
if (isset($costItem['id'])) { if (isset($costItem['id'])) {
$costItemId = market_management_require_positive_id($costItem['id'], 'cost_item_id'); $costItemId = market_management_require_positive_id($costItem['id'], 'cost_item_id');
unset($costItem['id']); unset($costItem['id']);