Compare commits

...
14 changed files with 621 additions and 54 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
10. `db/migrations/0014_require_human_lot_activation.sql`
11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.sql`
13. `db/migrations/0017_manufacturer_lot_traceability.sql`
## Beispielausfuehrung
@@ -0,0 +1,53 @@
BEGIN;
CREATE TABLE party_role (
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
role_code TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT pk_party_role PRIMARY KEY (party_id, role_code),
CONSTRAINT chk_party_role_code CHECK (role_code IN ('manufacturer'))
);
CREATE INDEX idx_party_role_code ON party_role(role_code);
ALTER TABLE stock_lot
RENAME COLUMN supplier_lot_number TO manufacturer_lot_number;
ALTER TABLE stock_lot
ADD COLUMN manufacturer_party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT;
INSERT INTO party (type, name, status, created_at, updated_at)
SELECT 'supplier', MIN(TRIM(supplier_name)), 'active', NOW(), NOW()
FROM stock_lot
WHERE supplier_name IS NOT NULL
AND TRIM(supplier_name) <> ''
AND NOT EXISTS (
SELECT 1
FROM party p
WHERE LOWER(TRIM(p.name)) = LOWER(TRIM(stock_lot.supplier_name))
)
GROUP BY LOWER(TRIM(supplier_name));
INSERT INTO party_role (party_id, role_code)
SELECT p.id, 'manufacturer'
FROM party p
WHERE EXISTS (
SELECT 1
FROM stock_lot sl
WHERE sl.supplier_name IS NOT NULL
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name))
)
ON CONFLICT (party_id, role_code) DO NOTHING;
UPDATE stock_lot sl
SET manufacturer_party_id = p.id
FROM party p
WHERE sl.supplier_name IS NOT NULL
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name));
ALTER TABLE stock_lot
DROP COLUMN supplier_name;
CREATE INDEX idx_stock_lot_manufacturer_party ON stock_lot(manufacturer_party_id);
COMMIT;
@@ -11,6 +11,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
### `kontakte`
- `party`
- `party_role`
- `address`
- `contact`
- `party_external_identity`
+1
View File
@@ -14,6 +14,7 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
## Writes
- Kontaktstamm
- Herstellerrollen im Parteien-Stamm
- Adressen
- Kommunikationsdaten
+3
View File
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
- Warenabgang
- Umlagerung
- Chargenpflege
- Verknüpfung von Naurua- und Hersteller-Chargen
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
## Reads
@@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen.
- `modules/erp/lager/service.php`
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten.
+144
View File
@@ -374,6 +374,150 @@ function get_sales_order_overview(PDO $pdo, array $filters = []): array
];
}
function get_sales_order_detail(PDO $pdo, int $orderId): ?array
{
$orderStmt = $pdo->prepare(
"SELECT
so.id,
so.external_ref,
so.order_date,
so.order_source,
so.order_status,
so.payment_status,
so.amount_net,
so.amount_shipping,
so.amount_tax,
so.amount_discount,
so.total_amount,
so.currency,
COALESCE(ad.first_name, '') AS first_name,
COALESCE(ad.last_name, '') AS last_name,
COALESCE(ad.street, '') AS street,
COALESCE(ad.house_number, '') AS house_number,
COALESCE(ad.zip, '') AS zip,
COALESCE(ad.city, '') AS city,
COALESCE(ad.country_name, '') AS country_name,
COALESCE(pm.label, '') AS payment_method,
COALESCE(sm.label, '') AS shipping_method
FROM sales_order so
LEFT JOIN LATERAL (
SELECT a.first_name, a.last_name, a.street, a.house_number, a.zip, a.city, a.country_name
FROM address a
WHERE a.party_id = so.party_id AND a.type IN ('shipping', 'billing')
ORDER BY CASE WHEN a.type = 'shipping' THEN 0 ELSE 1 END, a.id DESC
LIMIT 1
) ad ON TRUE
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
WHERE so.id = :order_id"
);
$orderStmt->execute([':order_id' => $orderId]);
$order = $orderStmt->fetch();
if (!is_array($order)) {
return null;
}
$lineStmt = $pdo->prepare(
"SELECT
sol.id,
sol.line_no,
sol.sellable_item_id,
COALESCE(si.item_code, sol.raw_external_article_number, '') AS item_code,
COALESCE(si.display_name, sol.raw_external_title, '') AS item_name,
COALESCE(sol.raw_external_article_number, '') AS raw_article_number,
COALESCE(sol.raw_external_title, '') AS raw_title,
sol.sku,
sol.qty,
sol.qty_cancelled,
sol.line_status,
sol.unit_price,
sol.line_total,
sic.product_id,
p.name AS product_name,
sic.qty_per_item,
a.id AS allocation_id,
a.qty AS allocated_qty,
a.allocation_status,
sl.lot_number,
sl.status AS lot_status
FROM sales_order_line sol
LEFT JOIN sellable_item si ON si.id = sol.sellable_item_id
LEFT JOIN sellable_item_component sic ON sic.sellable_item_id = sol.sellable_item_id
LEFT JOIN product p ON p.id = sic.product_id
LEFT JOIN sales_order_line_lot_allocation a
ON a.sales_order_line_id = sol.id AND a.product_id = sic.product_id
LEFT JOIN stock_lot sl ON sl.id = a.lot_id
WHERE sol.sales_order_id = :order_id
ORDER BY sol.line_no, p.name NULLS LAST, a.id"
);
$lineStmt->execute([':order_id' => $orderId]);
$lines = [];
foreach ($lineStmt->fetchAll() as $row) {
$lineId = (int) $row['id'];
if (!isset($lines[$lineId])) {
$lines[$lineId] = [
'id' => $lineId,
'line_no' => (int) $row['line_no'],
'item_code' => (string) $row['item_code'],
'item_name' => (string) $row['item_name'],
'raw_article_number' => (string) $row['raw_article_number'],
'raw_title' => (string) $row['raw_title'],
'sku' => (string) ($row['sku'] ?? ''),
'qty' => (float) $row['qty'],
'qty_cancelled' => (float) $row['qty_cancelled'],
'line_status' => (string) $row['line_status'],
'unit_price' => $row['unit_price'] !== null ? (float) $row['unit_price'] : null,
'line_total' => $row['line_total'] !== null ? (float) $row['line_total'] : null,
'product_allocations' => [],
];
}
if ($row['product_id'] !== null) {
$lines[$lineId]['product_allocations'][] = [
'product_id' => (int) $row['product_id'],
'product_name' => (string) $row['product_name'],
'requested_qty' => (float) $row['qty'] * (float) $row['qty_per_item'],
'allocated_qty' => $row['allocated_qty'] !== null ? (float) $row['allocated_qty'] : null,
'allocation_status' => $row['allocation_status'] !== null ? (string) $row['allocation_status'] : null,
'lot_number' => $row['lot_number'] !== null ? (string) $row['lot_number'] : null,
'lot_status' => $row['lot_status'] !== null ? (string) $row['lot_status'] : null,
];
}
}
$detail = [
'id' => (int) $order['id'],
'external_ref' => (string) $order['external_ref'],
'order_date' => (string) $order['order_date'],
'order_source' => (string) $order['order_source'],
'order_status' => (string) $order['order_status'],
'payment_status' => (string) $order['payment_status'],
'payment_method' => (string) $order['payment_method'],
'shipping_method' => (string) $order['shipping_method'],
'customer' => [
'first_name' => (string) $order['first_name'],
'last_name' => (string) $order['last_name'],
'street' => (string) $order['street'],
'house_number' => (string) $order['house_number'],
'zip' => (string) $order['zip'],
'city' => (string) $order['city'],
'country_name' => (string) $order['country_name'],
],
'amounts' => [
'net' => $order['amount_net'] !== null ? (float) $order['amount_net'] : null,
'shipping' => $order['amount_shipping'] !== null ? (float) $order['amount_shipping'] : null,
'tax' => $order['amount_tax'] !== null ? (float) $order['amount_tax'] : null,
'discount' => $order['amount_discount'] !== null ? (float) $order['amount_discount'] : null,
'total' => $order['total_amount'] !== null ? (float) $order['total_amount'] : null,
'currency' => (string) $order['currency'],
],
'lines' => array_values($lines),
];
return $detail;
}
function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array
{
$limit = max(1, min(2000, $limit));
+82
View File
@@ -103,3 +103,85 @@ function upsert_addresses(PDO $pdo, int $partyId, array $data): void
]);
}
}
function get_manufacturer_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT p.id, p.name, p.email, p.phone
FROM party p
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
WHERE p.status = 'active'
ORDER BY p.name ASC, p.id ASC"
);
return array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'email' => (string) ($row['email'] ?? ''),
'phone' => (string) ($row['phone'] ?? ''),
],
$stmt->fetchAll()
);
}
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
{
$name = trim($name);
$email = $email !== null ? trim($email) : null;
$phone = $phone !== null ? trim($phone) : null;
if ($name === '') {
throw new InvalidArgumentException('Herstellername fehlt');
}
$existingStmt = $pdo->prepare(
"SELECT p.id, p.name, p.email, p.phone
FROM party p
WHERE LOWER(TRIM(p.name)) = LOWER(:name)
LIMIT 1
FOR UPDATE"
);
$existingStmt->execute([':name' => $name]);
$existing = $existingStmt->fetch();
if (is_array($existing)) {
$roleStmt = $pdo->prepare(
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')
ON CONFLICT (party_id, role_code) DO NOTHING"
);
$roleStmt->execute([':party_id' => (int) $existing['id']]);
return [
'id' => (int) $existing['id'],
'name' => (string) $existing['name'],
'email' => (string) ($existing['email'] ?? ''),
'phone' => (string) ($existing['phone'] ?? ''),
];
}
$stmt = $pdo->prepare(
"INSERT INTO party (type, name, email, phone, status, created_at, updated_at)
VALUES ('supplier', :name, :email, :phone, 'active', NOW(), NOW())
RETURNING id, name, email, phone"
);
$stmt->execute([
':name' => $name,
':email' => $email !== '' ? $email : null,
':phone' => $phone !== '' ? $phone : null,
]);
$party = $stmt->fetch();
if (!is_array($party)) {
throw new RuntimeException('Hersteller konnte nicht angelegt werden');
}
$roleStmt = $pdo->prepare(
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')"
);
$roleStmt->execute([':party_id' => (int) $party['id']]);
return [
'id' => (int) $party['id'],
'name' => (string) $party['name'],
'email' => (string) ($party['email'] ?? ''),
'phone' => (string) ($party['phone'] ?? ''),
];
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../shared/db.php';
require_once __DIR__ . '/../../../shared/auth/service.php';
require_once __DIR__ . '/../../kontakte/service.php';
require_once __DIR__ . '/../service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
try {
$pdo = connect_database($env);
auth_bootstrap_session();
$currentUser = auth_current_user($pdo);
if ($currentUser === null) {
json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
}
$jsonInput = file_get_contents('php://input');
if ($jsonInput === false || trim($jsonInput) === '') {
json_response(400, ['ok' => false, 'error' => 'Empty payload']);
}
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !auth_validate_csrf_token($data['csrfToken'] ?? null)) {
json_response(403, ['ok' => false, 'error' => 'Invalid security token']);
}
$action = trim((string) ($data['action'] ?? ''));
if ($action === 'create_manufacturer') {
$manufacturer = create_manufacturer(
$pdo,
(string) ($data['name'] ?? ''),
isset($data['email']) ? (string) $data['email'] : null,
isset($data['phone']) ? (string) $data['phone'] : null
);
json_response(201, ['ok' => true, 'manufacturer' => $manufacturer]);
}
if ($action === 'update_lot') {
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === ''
? null
: (int) $manufacturerPartyId;
$updatedLot = update_stock_lot_traceability(
$pdo,
(int) ($data['lotId'] ?? 0),
$manufacturerPartyId,
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
);
json_response(200, ['ok' => true, 'lot' => $updatedLot]);
}
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
} catch (JsonException) {
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
} catch (InvalidArgumentException $error) {
json_response(422, ['ok' => false, 'error' => $error->getMessage()]);
} catch (Throwable $error) {
json_response(500, ['ok' => false, 'error' => 'Internal server error: ' . $error->getMessage()]);
}
+137 -4
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
require_once __DIR__ . '/forecast.php';
require_once __DIR__ . '/../../system/audit.php';
function get_default_location_ids(PDO $pdo): array
{
@@ -76,8 +77,13 @@ function get_lager_product_overview(PDO $pdo): array
p.uom,
COALESCE(SUM(v.qty_net), 0) AS available_qty,
sl.lot_number,
sl.supplier_lot_number,
sl.id AS lot_id,
sl.manufacturer_party_id,
sl.manufacturer_lot_number,
mp.name AS manufacturer_name,
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
sl.sellout_date,
sl.sellout_forecast_status,
(
@@ -92,11 +98,13 @@ function get_lager_product_overview(PDO $pdo): array
LEFT JOIN stock_lot sl
ON sl.product_id = p.id
AND sl.status = 'current'
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom, sl.lot_number, sl.supplier_lot_number,
sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status
GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id,
sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
sl.sellout_date, sl.sellout_forecast_status
ORDER BY p.name ASC, p.id ASC"
);
@@ -107,9 +115,14 @@ function get_lager_product_overview(PDO $pdo): array
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'],
'lot_number' => $row['lot_number'],
'supplier_lot_number' => $row['supplier_lot_number'],
'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
'virtual_lot_number' => $row['virtual_lot_number'],
@@ -118,6 +131,126 @@ function get_lager_product_overview(PDO $pdo): array
);
}
function update_stock_lot_traceability(
PDO $pdo,
int $lotId,
?int $manufacturerPartyId,
?string $manufacturerLotNumber,
?string $mfgDate,
?string $expiryDate,
?string $purchaseDate,
string $changedBy
): array {
if ($lotId <= 0) {
throw new InvalidArgumentException('Chargen-ID fehlt');
}
$manufacturerLotNumber = $manufacturerLotNumber !== null ? trim($manufacturerLotNumber) : null;
if ($manufacturerLotNumber === '') {
$manufacturerLotNumber = null;
}
$dates = [
'mfg_date' => $mfgDate,
'expiry_date' => $expiryDate,
'purchase_date' => $purchaseDate,
];
foreach ($dates as $field => $value) {
if ($value !== null && $value !== '') {
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
if ($parsed === false || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0))) {
throw new InvalidArgumentException("Ungueltiges Datum fuer {$field}");
}
}
$dates[$field] = $value !== null && $value !== '' ? $value : null;
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
mfg_date, expiry_date, purchase_date, status
FROM stock_lot
WHERE id = :id
FOR UPDATE"
);
$lotStmt->execute([':id' => $lotId]);
$before = $lotStmt->fetch();
if (!is_array($before)) {
throw new RuntimeException('Charge nicht gefunden');
}
if ((string) $before['status'] === 'virtual_shortage') {
throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden');
}
if ($manufacturerPartyId !== null) {
$manufacturerStmt = $pdo->prepare(
"SELECT p.id
FROM party p
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
WHERE p.id = :id AND p.status = 'active'
LIMIT 1"
);
$manufacturerStmt->execute([':id' => $manufacturerPartyId]);
if ($manufacturerStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Hersteller ist nicht aktiv oder nicht als Hersteller gepflegt');
}
}
if ($manufacturerPartyId === null && $manufacturerLotNumber !== null) {
throw new InvalidArgumentException('Hersteller-Chargennummer benoetigt einen Hersteller');
}
if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Hersteller-Chargennummer fehlt');
}
$updateStmt = $pdo->prepare(
"UPDATE stock_lot
SET manufacturer_party_id = :manufacturer_party_id,
manufacturer_lot_number = :manufacturer_lot_number,
mfg_date = :mfg_date,
expiry_date = :expiry_date,
purchase_date = :purchase_date,
updated_at = NOW()
WHERE id = :id"
);
$updateStmt->execute([
':manufacturer_party_id' => $manufacturerPartyId,
':manufacturer_lot_number' => $manufacturerLotNumber,
':mfg_date' => $dates['mfg_date'],
':expiry_date' => $dates['expiry_date'],
':purchase_date' => $dates['purchase_date'],
':id' => $lotId,
]);
$after = $before;
$after['manufacturer_party_id'] = $manufacturerPartyId;
$after['manufacturer_lot_number'] = $manufacturerLotNumber;
$after['mfg_date'] = $dates['mfg_date'];
$after['expiry_date'] = $dates['expiry_date'];
$after['purchase_date'] = $dates['purchase_date'];
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'traceability_updated', $changedBy, $before, $after, [
'module' => 'erp.lager',
]);
if ($startedTransaction) {
$pdo->commit();
}
return $after;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
+59 -49
View File
@@ -172,21 +172,7 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$html[] = '<button class="sg-interaction-element sg-hyperlink" type="button" data-order-drawer-close>schliessen</button>';
$html[] = '</div>';
$html[] = '<div class="sg-card-list-page-drawer__content">';
$html[] = '<article class="sg-card sg-vsf-drawer-card" data-component="card" data-pattern="card" aria-label="Bestell-Details">';
$html[] = '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header">';
$html[] = '<div class="sg-strong">Bestell-Details</div>';
$html[] = '</header>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content">';
$html[] = '<p class="sg-body"><strong>Bestellnummer:</strong> <span data-order-drawer-order-number></span></p>';
$html[] = '<p class="sg-body"><strong>Bestelldatum:</strong> <span data-order-drawer-order-date></span></p>';
$html[] = '<p class="sg-body"><strong>Lieferadresse:</strong></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-1></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-2></p>';
$html[] = '<p class="sg-body" data-order-drawer-address-line-3></p>';
$html[] = '<p class="sg-body"><strong>Gesamtsumme:</strong> <span data-order-drawer-total></span></p>';
$html[] = '<p class="sg-body">Detailinhalt wird noch definiert.</p>';
$html[] = '</div>';
$html[] = '</article>';
$html[] = '<div data-order-drawer-cards><p class="sg-body" data-order-drawer-loading>Bestelldetails werden geladen …</p></div>';
$html[] = '</div>';
$html[] = '</aside>';
@@ -256,9 +242,14 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$detailData = ' data-lager-drawer-forecast="' . auth_escape_html($forecastText) . '"'
. ' data-lager-drawer-forecast-warning="' . ($forecastWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-forecast-mhd-warning="' . ($forecastMhdWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-lot-id="' . auth_escape_html((string) ($row['lot_id'] ?? '')) . '"'
. ' data-lager-drawer-lot-number="' . auth_escape_html((string) ($row['lot_number'] ?? '')) . '"'
. ' data-lager-drawer-supplier-lot-number="' . auth_escape_html((string) ($row['supplier_lot_number'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-party-id="' . auth_escape_html((string) ($row['manufacturer_party_id'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-name="' . auth_escape_html((string) ($row['manufacturer_name'] ?? '')) . '"'
. ' data-lager-drawer-manufacturer-lot-number="' . auth_escape_html((string) ($row['manufacturer_lot_number'] ?? '')) . '"'
. ' data-lager-drawer-mfg-date="' . auth_escape_html((string) ($row['mfg_date'] ?? '')) . '"'
. ' data-lager-drawer-expiry-date="' . auth_escape_html((string) ($row['expiry_date'] ?? '')) . '"'
. ' data-lager-drawer-purchase-date="' . auth_escape_html((string) ($row['purchase_date'] ?? '')) . '"'
. ' data-lager-drawer-virtual-lot-number="' . auth_escape_html((string) ($row['virtual_lot_number'] ?? '')) . '"';
}
@@ -278,9 +269,14 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
return implode('', $html);
}
function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): string
function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, array $lagerManufacturers, string $csrfToken): string
{
return '<div data-lager-content="true">'
$manufacturerOptions = '<option value="">Hersteller auswählen</option>';
foreach ($lagerManufacturers as $manufacturer) {
$manufacturerOptions .= '<option value="' . (int) ($manufacturer['id'] ?? 0) . '">' . auth_escape_html((string) ($manufacturer['name'] ?? '')) . '</option>';
}
return '<div data-lager-content="true" data-lager-csrf-token="' . auth_escape_html($csrfToken) . '">'
. '<section class="sg-card-list-page" aria-label="Lager">'
. auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products')
. auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles')
@@ -299,7 +295,22 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s
. '</article>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" aria-label="Lagercharge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Lagercharge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-drawer-lot-number></span><br><strong>Hersteller-Chargennummer:</strong> <span data-lager-drawer-supplier-lot-number></span><br><strong>MHD:</strong> <span data-lager-drawer-expiry-date></span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-drawer-lot-number></span><br><strong>Hersteller:</strong> <span data-lager-drawer-manufacturer-name></span><br><strong>Hersteller-Chargennummer:</strong> <span data-lager-drawer-manufacturer-lot-number></span><br><strong>Herstellungsdatum:</strong> <span data-lager-drawer-mfg-date></span><br><strong>MHD:</strong> <span data-lager-drawer-expiry-date></span><br><strong>Einkaufsdatum:</strong> <span data-lager-drawer-purchase-date></span></p><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-edit-open>Charge bearbeiten</button></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-edit-form hidden>'
. '<form class="sg-form-sections-card" data-lager-edit-form-element><div class="sg-form-sections-card__body">'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-manufacturer">Hersteller</label><select class="sg-interaction-element sg-input-single-line" id="lager-manufacturer" data-lager-manufacturer>' . $manufacturerOptions . '</select></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-manufacturer-lot">Hersteller-Chargennummer</label><input class="sg-interaction-element sg-input-single-line" type="text" id="lager-manufacturer-lot" data-lager-manufacturer-lot></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-mfg-date">Herstellungsdatum</label><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-mfg-date" data-lager-mfg-date></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-expiry-date">MHD</label><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-expiry-date" data-lager-expiry-date></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-purchase-date">Einkaufsdatum</label><input class="sg-interaction-element sg-input-single-line" type="date" id="lager-purchase-date" data-lager-purchase-date></div>'
. '<div class="sg-component-row sg-basic-card__actions"><button class="sg-interaction-element sg-button sg-button--active" type="submit">Speichern</button><button class="sg-interaction-element sg-button" type="button" data-lager-edit-close>Abbrechen</button></div>'
. '<p class="sg-body" data-lager-edit-status aria-live="polite"></p></div></form>'
. '<form class="sg-form-sections-card" data-lager-manufacturer-form><div class="sg-form-sections-card__body"><h3 class="sg-strong">Neuen Hersteller anlegen</h3>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-new-manufacturer-name">Name</label><input class="sg-interaction-element sg-input-single-line" type="text" id="lager-new-manufacturer-name" data-lager-new-manufacturer-name required></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-new-manufacturer-email">E-Mail</label><input class="sg-interaction-element sg-input-single-line" type="email" id="lager-new-manufacturer-email" data-lager-new-manufacturer-email></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-label" for="lager-new-manufacturer-phone">Telefon</label><input class="sg-interaction-element sg-input-single-line" type="text" id="lager-new-manufacturer-phone" data-lager-new-manufacturer-phone></div>'
. '<div class="sg-component-row sg-basic-card__actions"><button class="sg-interaction-element sg-button sg-button--active" type="submit">Hersteller anlegen</button></div></div></form>'
. '</div></article>'
. '</article>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="virtual-lot" aria-label="Virtuelle Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Virtuelle Charge</div></div>'
@@ -310,7 +321,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s
. '</div>';
}
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = []): void
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void
{
$otcProductRows = '';
foreach ($otcProducts as $product) {
@@ -344,7 +355,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$bestellungenContent = auth_render_bestellungen_content($bestellungenTable);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token());
$moduleContentCards = json_encode(
[
'Bestellungen' => $bestellungenContent,
@@ -655,10 +666,13 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true';";
echo " const warningText = warning ? forecast : 'Keine akute Warnung.';";
echo " const lotNumber = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt';";
echo " const supplierLotNumber = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt';";
echo " const manufacturerName = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt';";
echo " const manufacturerLotNumber = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt';";
echo " const mfgDate = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt';";
echo " const expiryDate = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt';";
echo " const purchaseDate = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt';";
echo " const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || '';";
echo " return ['<div class=\"sg-lager-mobile-detail-host\" data-lager-mobile-detail-host=\"true\">', '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">', '<div class=\"sg-navigation-card-block\">', '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">', '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">', '<div class=\"sg-navigation-card-center\">', '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button>', '</div></div></article></div></div>', '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Lager-Details\">', '<header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue\"><div class=\"sg-strong\">' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '</div></header>', '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>Name:</strong> ' + escapeLagerHtml(name) + '</p><p class=\"sg-body\"><strong>' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + '</strong> ' + escapeLagerHtml(value) + '</p>' + (isProduct ? '<p class=\"sg-body\"><strong>Warnungen:</strong> ' + escapeLagerHtml(warningText) + '</p><p class=\"sg-body\"><strong>Naurua-Chargennummer:</strong> ' + escapeLagerHtml(lotNumber) + '<br><strong>Hersteller-Chargennummer:</strong> ' + escapeLagerHtml(supplierLotNumber) + '<br><strong>MHD:</strong> ' + escapeLagerHtml(expiryDate) + '</p><p class=\"sg-body\"><strong>Virtuelle Charge:</strong> ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '</p>' : '') + '</div></article></div>'].join('');";
echo " return ['<div class=\"sg-lager-mobile-detail-host\" data-lager-mobile-detail-host=\"true\">', '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">', '<div class=\"sg-navigation-card-block\">', '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">', '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">', '<div class=\"sg-navigation-card-center\">', '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button>', '</div></div></article></div></div>', '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Lager-Details\">', '<header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue\"><div class=\"sg-strong\">' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '</div></header>', '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>Name:</strong> ' + escapeLagerHtml(name) + '</p><p class=\"sg-body\"><strong>' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + '</strong> ' + escapeLagerHtml(value) + '</p>' + (isProduct ? '<p class=\"sg-body\"><strong>Warnungen:</strong> ' + escapeLagerHtml(warningText) + '</p><p class=\"sg-body\"><strong>Naurua-Chargennummer:</strong> ' + escapeLagerHtml(lotNumber) + '<br><strong>Hersteller:</strong> ' + escapeLagerHtml(manufacturerName) + '<br><strong>Hersteller-Chargennummer:</strong> ' + escapeLagerHtml(manufacturerLotNumber) + '<br><strong>Herstellungsdatum:</strong> ' + escapeLagerHtml(mfgDate) + '<br><strong>MHD:</strong> ' + escapeLagerHtml(expiryDate) + '<br><strong>Einkaufsdatum:</strong> ' + escapeLagerHtml(purchaseDate) + '</p><p class=\"sg-body\"><strong>Virtuelle Charge:</strong> ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '</p>' : '') + '</div></article></div>'].join('');";
echo " };";
echo " const restoreLagerMobileDetail = () => {";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
@@ -683,9 +697,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const closeDrawer = () => { setDrawerState(false); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); };";
echo " contentRoot.addEventListener('click', (event) => {";
echo " const trigger = event.target.closest('[data-lager-drawer-open]');";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-supplier-lot-number]').textContent = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = trigger.dataset.lagerDrawerManufacturerPartyId || ''; } const manufacturerLot = drawer.querySelector('[data-lager-manufacturer-lot]'); if (manufacturerLot) { manufacturerLot.value = trigger.dataset.lagerDrawerManufacturerLotNumber || ''; } const mfgDate = drawer.querySelector('[data-lager-mfg-date]'); if (mfgDate) { mfgDate.value = trigger.dataset.lagerDrawerMfgDate || ''; } const expiryDate = drawer.querySelector('[data-lager-expiry-date]'); if (expiryDate) { expiryDate.value = trigger.dataset.lagerDrawerExpiryDate || ''; } const purchaseDate = drawer.querySelector('[data-lager-purchase-date]'); if (purchaseDate) { purchaseDate.value = trigger.dataset.lagerDrawerPurchaseDate || ''; } const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }";
echo " });";
echo " const postLagerChange = async (payload) => { const response = await fetch('/api/lager-charge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, csrfToken: contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || '' }) }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Speichern fehlgeschlagen'); } return result; };";
echo " contentRoot.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-edit-open]'); if (editButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-edit-close]'); if (closeButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } });";
echo " contentRoot.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); const manufacturerForm = event.target.closest('[data-lager-manufacturer-form]'); if (!editForm && !manufacturerForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { if (manufacturerForm) { const result = await postLagerChange({ action: 'create_manufacturer', name: manufacturerForm.querySelector('[data-lager-new-manufacturer-name]')?.value || '', email: manufacturerForm.querySelector('[data-lager-new-manufacturer-email]')?.value || '', phone: manufacturerForm.querySelector('[data-lager-new-manufacturer-phone]')?.value || '' }); const select = drawer?.querySelector('[data-lager-manufacturer]'); if (select && result.manufacturer) { const option = document.createElement('option'); option.value = String(result.manufacturer.id); option.textContent = result.manufacturer.name; select.appendChild(option); select.value = String(result.manufacturer.id); } manufacturerForm.reset(); if (status) { status.textContent = 'Hersteller angelegt. Bitte Charge speichern.'; } return; } await postLagerChange({ action: 'update_lot', lotId: drawer?.dataset.lagerActiveLotId || '', manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '' }); window.location.reload(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-detail-back]')) { event.preventDefault(); closeLagerMobileDetail(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeLagerMobileDetail(); } });";
echo " window.addEventListener('popstate', (event) => { if (lagerMobileDetailOpen && (!event.state || event.state.lagerView !== 'detail')) { restoreLagerMobileDetail(); } });";
@@ -746,6 +763,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const zip = trigger.dataset.orderZip || '';";
echo " const city = trigger.dataset.orderCity || '';";
echo " const country = trigger.dataset.orderCountry || '';";
echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';";
echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " return [";
@@ -761,7 +779,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " '</article>',";
echo " '</div>',";
echo " '</div>',";
echo " '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Bestell-Details\">',";
echo " '<div data-bestellungen-mobile-cards><article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Bestell-Details\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">',";
echo " '<p class=\"sg-body\"><strong>Bestellnummer:</strong> <span>' + escapeHtml(orderNumber) + '</span></p>',";
echo " '<p class=\"sg-body\"><strong>Bestelldatum:</strong> <span>' + escapeHtml(orderDate) + '</span></p>',";
@@ -772,7 +790,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " '<p class=\"sg-body\"><strong>Gesamtsumme:</strong> <span>' + escapeHtml(total) + '</span></p>',";
echo " '<p class=\"sg-body\">Detailinhalt wird noch definiert.</p>',";
echo " '</div>',";
echo " '</article>',";
echo " '</article></div>',";
echo " '</div>'";
echo " ].join('');";
echo " };";
@@ -802,30 +820,21 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " return;";
echo " }";
echo " const drawerTitle = drawer.querySelector('[data-order-drawer-title]');";
echo " const drawerOrderNumber = drawer.querySelector('[data-order-drawer-order-number]');";
echo " const drawerOrderDate = drawer.querySelector('[data-order-drawer-order-date]');";
echo " const drawerAddressLine1 = drawer.querySelector('[data-order-drawer-address-line-1]');";
echo " const drawerAddressLine2 = drawer.querySelector('[data-order-drawer-address-line-2]');";
echo " const drawerAddressLine3 = drawer.querySelector('[data-order-drawer-address-line-3]');";
echo " const drawerTotal = drawer.querySelector('[data-order-drawer-total]');";
echo " const orderNumber = trigger.dataset.orderNumber || '';";
echo " const orderDate = trigger.dataset.orderDate || '';";
echo " const total = trigger.dataset.orderTotal || '';";
echo " const street = trigger.dataset.orderStreet || '';";
echo " const houseNumber = trigger.dataset.orderHouseNumber || '';";
echo " const zip = trigger.dataset.orderZip || '';";
echo " const city = trigger.dataset.orderCity || '';";
echo " const country = trigger.dataset.orderCountry || '';";
echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cards = drawer.querySelector('[data-order-drawer-cards]');";
echo " const orderId = trigger.dataset.orderId || '';";
echo " const formatAmount = (value, currency) => value === null || value === undefined ? 'Nicht vorhanden' : currency + ' ' + Number(value).toFixed(2);";
echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';";
echo " const value = (label, content) => '<p class=\"sg-body\"><strong>' + escapeHtml(label) + ':</strong> ' + escapeHtml(content || 'Nicht vorhanden') + '</p>';";
echo " const renderCards = (order) => {";
echo " const c = order.customer || {}; const a = order.amounts || {}; const address = [[c.street, c.house_number].filter(Boolean).join(' '), [c.zip, c.city].filter(Boolean).join(' '), c.country_name].filter(Boolean).join('<br>');";
echo " const lines = (order.lines || []).map((line) => '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>' + escapeHtml(line.item_name) + '</strong> · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + '</p>' + value('Positionsstatus', line.line_status) + value('Einzelpreis', formatAmount(line.unit_price, a.currency || 'CHF')) + value('Positionssumme', formatAmount(line.line_total, a.currency || 'CHF')) + '</div>').join('');";
echo " const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<div class=\"sg-body\"><strong>' + escapeHtml(item.product_name) + ':</strong> ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocated_qty === null ? 'Nicht zugeordnet' : String(item.allocated_qty)) + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + (item.lot_status === 'virtual_shortage' ? ' · Virtuelle Fehlbestandscharge' : '') + '</div>')).join('');";
echo " return '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Bestellübersicht\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">' + value('Bestellnummer', order.external_ref) + value('Bestelldatum', formatDate(order.order_date)) + value('Verkaufskanal', order.order_source) + value('Bestellstatus', order.order_status) + value('Zahlungsstatus', order.payment_status) + value('Zahlungsart', order.payment_method) + value('Versandart', order.shipping_method) + '<p class=\"sg-body\"><strong>Kunde:</strong> ' + escapeHtml([c.first_name, c.last_name].filter(Boolean).join(' ') || 'Nicht vorhanden') + '</p><p class=\"sg-body\"><strong>Lieferadresse:</strong><br>' + address + '</p>' + value('Netto', formatAmount(a.net, a.currency || 'CHF')) + value('Versand', formatAmount(a.shipping, a.currency || 'CHF')) + value('Rabatt', formatAmount(a.discount, a.currency || 'CHF')) + value('Steuer', formatAmount(a.tax, a.currency || 'CHF')) + value('Gesamtsumme', formatAmount(a.total, a.currency || 'CHF')) + '</div></article><article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Bestellte Artikel\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Bestellte Artikel</div></header>' + (lines || '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\">Keine Artikel vorhanden.</p></div>') + '</article><article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Chargenzuordnung\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>';";
echo " };";
echo " setDrawerState(true);";
echo " if (drawerTitle) { drawerTitle.textContent = orderNumber !== '' ? 'Bestellung ' + orderNumber : 'Bestellung'; }";
echo " if (drawerOrderNumber) { drawerOrderNumber.textContent = orderNumber; }";
echo " if (drawerOrderDate) { drawerOrderDate.textContent = orderDate; }";
echo " if (drawerAddressLine1) { drawerAddressLine1.textContent = streetLine; }";
echo " if (drawerAddressLine2) { drawerAddressLine2.textContent = cityLine; }";
echo " if (drawerAddressLine3) { drawerAddressLine3.textContent = country; }";
echo " if (drawerTotal) { drawerTotal.textContent = total; }";
echo " if (drawerTitle) { drawerTitle.textContent = trigger.dataset.orderNumber ? 'Bestellung ' + trigger.dataset.orderNumber : 'Bestellung'; }";
echo " if (cards) { cards.innerHTML = '<p class=\"sg-body\">Bestelldetails werden geladen …</p>'; }";
echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(orderId), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } if (cards) { cards.innerHTML = renderCards(payload.order); } }).catch((error) => { if (cards) { cards.innerHTML = '<p class=\"sg-body\">' + escapeHtml(error.message) + '</p>'; } });";
echo " contentRoot.querySelectorAll('[data-order-drawer-open]').forEach((button) => {";
echo " button.setAttribute('aria-expanded', String(button === trigger));";
echo " });";
@@ -848,6 +857,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " document.body.appendChild(bestellungenMobileDetailHost);";
echo " document.body.classList.add('sg-bestellungen-mobile-detail-open');";
echo " }";
echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(trigger.dataset.orderId || ''), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok || !bestellungenMobileDetailHost) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } const order = payload.order; const cards = bestellungenMobileDetailHost.querySelector('[data-bestellungen-mobile-cards]'); const lines = (order.lines || []).map((line) => '<p class=\"sg-body\"><strong>' + escapeHtml(line.item_name) + '</strong> · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + ' · Status: ' + escapeHtml(line.line_status) + '</p>').join(''); const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<p class=\"sg-body\"><strong>' + escapeHtml(item.product_name) + ':</strong> ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + '</p>')).join(''); if (cards) { cards.innerHTML = '<article class=\"sg-card sg-vsf-drawer-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>Bestellnummer:</strong> ' + escapeHtml(order.external_ref) + '</p><p class=\"sg-body\"><strong>Bestelldatum:</strong> ' + escapeHtml(formatDate(order.order_date)) + '</p><p class=\"sg-body\"><strong>Verkaufskanal:</strong> ' + escapeHtml(order.order_source) + '</p><p class=\"sg-body\"><strong>Status:</strong> ' + escapeHtml(order.order_status) + '</p></div></article><article class=\"sg-card sg-vsf-drawer-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Bestellte Artikel</div></header><div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">' + (lines || '<p class=\"sg-body\">Keine Artikel vorhanden.</p>') + '</div></article><article class=\"sg-card sg-vsf-drawer-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header\"><div class=\"sg-strong\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>'; } }).catch((error) => { const cards = bestellungenMobileDetailHost?.querySelector('[data-bestellungen-mobile-cards]'); if (cards) { cards.innerHTML = '<p class=\"sg-body\">' + escapeHtml(error.message) + '</p>'; } });";
echo " bestellungenDetailOpen = true;";
echo " window.history.pushState({ bestellungenView: 'detail', orderId: trigger.dataset.orderId || '' }, '', window.location.href);";
echo " };";
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
function system_write_audit_log(
PDO $pdo,
string $entityName,
string $entityId,
string $action,
?string $changedBy,
array $beforeData,
array $afterData,
array $context = []
): void {
$stmt = $pdo->prepare(
"INSERT INTO audit_log (
entity_name, entity_id, action, changed_by, before_data, after_data, context
) VALUES (
:entity_name, :entity_id, :action, :changed_by, :before_data::jsonb, :after_data::jsonb, :context::jsonb
)"
);
$stmt->execute([
':entity_name' => $entityName,
':entity_id' => $entityId,
':action' => $action,
':changed_by' => $changedBy,
':before_data' => json_encode($beforeData, JSON_THROW_ON_ERROR),
':after_data' => json_encode($afterData, JSON_THROW_ON_ERROR),
':context' => json_encode($context, JSON_THROW_ON_ERROR),
]);
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../modules/shared/db.php';
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
header('Content-Type: application/json; charset=utf-8');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
exit;
}
$orderId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!is_int($orderId) || $orderId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid order id']);
exit;
}
$detail = get_sales_order_detail($pdo, $orderId);
if ($detail === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Order not found']);
exit;
}
echo json_encode(['ok' => true, 'order' => $detail], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/api/lot.php';
+3 -1
View File
@@ -6,6 +6,7 @@ require_once __DIR__ . '/../modules/shared/auth/ui/login.php';
require_once __DIR__ . '/../modules/shared/auth/ui/home.php';
require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
@@ -48,6 +49,7 @@ $currentUser = auth_current_user($pdo);
if ($currentUser !== null) {
$otcProducts = get_otc_order_form_products($pdo);
$lagerProducts = get_lager_product_overview($pdo);
$lagerManufacturers = get_manufacturer_options($pdo);
$lagerArtikel = get_lager_artikel_overview($pdo);
$bestellungenTable = get_sales_order_overview($pdo, [
'search' => (string) ($_GET['bestellungen_search'] ?? ''),
@@ -60,7 +62,7 @@ if ($currentUser !== null) {
echo auth_render_bestellungen_content($bestellungenTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel);
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
exit;
}