Naurua-Chargennummer:
Hersteller-Chargennummer:
MHD:
Naurua-Chargennummer:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:
diff --git a/db/README.md b/db/README.md index d220baf..7073e8c 100644 --- a/db/README.md +++ b/db/README.md @@ -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 diff --git a/db/migrations/0017_manufacturer_lot_traceability.sql b/db/migrations/0017_manufacturer_lot_traceability.sql new file mode 100644 index 0000000..4046faf --- /dev/null +++ b/db/migrations/0017_manufacturer_lot_traceability.sql @@ -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; diff --git a/docs/modules/erp/lager/README.md b/docs/modules/erp/lager/README.md index a7ff902..e3c402b 100644 --- a/docs/modules/erp/lager/README.md +++ b/docs/modules/erp/lager/README.md @@ -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. diff --git a/modules/erp/kontakte/service.php b/modules/erp/kontakte/service.php index a606dce..14b101e 100644 --- a/modules/erp/kontakte/service.php +++ b/modules/erp/kontakte/service.php @@ -103,3 +103,81 @@ 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 + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer' + WHERE LOWER(TRIM(p.name)) = LOWER(:name) + LIMIT 1 + FOR UPDATE" + ); + $existingStmt->execute([':name' => $name]); + $existing = $existingStmt->fetch(); + if (is_array($existing)) { + 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'] ?? ''), + ]; +} diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php new file mode 100644 index 0000000..b1351af --- /dev/null +++ b/modules/erp/lager/api/lot.php @@ -0,0 +1,69 @@ + 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()]); +} diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 4097c21..f151734 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -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( diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index d31c9a2..9264a20 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -256,9 +256,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 +283,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 '
Naurua-Chargennummer:
Hersteller-Chargennummer:
MHD:
Naurua-Chargennummer:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:
Name: ' + escapeLagerHtml(name) + '
' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + ' ' + escapeLagerHtml(value) + '
' + (isProduct ? 'Warnungen: ' + escapeLagerHtml(warningText) + '
Naurua-Chargennummer: ' + escapeLagerHtml(lotNumber) + '
Hersteller-Chargennummer: ' + escapeLagerHtml(supplierLotNumber) + '
MHD: ' + escapeLagerHtml(expiryDate) + '
Virtuelle Charge: ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '
' : '') + 'Name: ' + escapeLagerHtml(name) + '
' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + ' ' + escapeLagerHtml(value) + '
' + (isProduct ? 'Warnungen: ' + escapeLagerHtml(warningText) + '
Naurua-Chargennummer: ' + escapeLagerHtml(lotNumber) + '
Hersteller: ' + escapeLagerHtml(manufacturerName) + '
Hersteller-Chargennummer: ' + escapeLagerHtml(manufacturerLotNumber) + '
Herstellungsdatum: ' + escapeLagerHtml(mfgDate) + '
MHD: ' + escapeLagerHtml(expiryDate) + '
Einkaufsdatum: ' + escapeLagerHtml(purchaseDate) + '
Virtuelle Charge: ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '
' : '') + '