diff --git a/db/README.md b/db/README.md index 59d5e01..a3a8f84 100644 --- a/db/README.md +++ b/db/README.md @@ -12,6 +12,7 @@ 8. `db/migrations/0008_shopify_sync_technical.sql` 9. `db/migrations/0013_virtual_shortage_lot_allocations.sql` 10. `db/migrations/0014_require_human_lot_activation.sql` +11. `db/migrations/0015_sales_order_payments.sql` ## Beispielausfuehrung diff --git a/db/migrations/0015_sales_order_payments.sql b/db/migrations/0015_sales_order_payments.sql new file mode 100644 index 0000000..d759c8a --- /dev/null +++ b/db/migrations/0015_sales_order_payments.sql @@ -0,0 +1,12 @@ +CREATE TABLE sales_order_payment ( + id BIGSERIAL PRIMARY KEY, + sales_order_id BIGINT NOT NULL REFERENCES sales_order(id) ON DELETE CASCADE, + payment_method_id BIGINT NOT NULL REFERENCES payment_method(id) ON DELETE RESTRICT, + amount NUMERIC(14, 2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_sales_order_payment_amount_positive CHECK (amount > 0) +); + +CREATE INDEX idx_sales_order_payment_order ON sales_order_payment(sales_order_id); +CREATE INDEX idx_sales_order_payment_method ON sales_order_payment(payment_method_id); diff --git a/modules/erp/direktverkauf/api/otc-order.php b/modules/erp/direktverkauf/api/otc-order.php index ac53a6e..9ea4085 100644 --- a/modules/erp/direktverkauf/api/otc-order.php +++ b/modules/erp/direktverkauf/api/otc-order.php @@ -30,7 +30,7 @@ if (!is_array($data)) { json_response(400, ['ok' => false, 'error' => 'JSON object expected']); } -$required = ['products', 'totalPrice', 'orderDate', 'paymentMethod', 'billing']; +$required = ['products', 'totalPrice', 'orderDate', 'payments', 'billing']; foreach ($required as $field) { if (!array_key_exists($field, $data)) { json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]); @@ -40,13 +40,17 @@ foreach ($required as $field) { $products = $data['products']; $totalPrice = parse_number($data['totalPrice']); $orderDate = trim((string) $data['orderDate']); -$paymentMethodCode = trim((string) $data['paymentMethod']); +$payments = $data['payments']; $billing = is_array($data['billing']) ? $data['billing'] : []; if (!is_array($products) || count($products) === 0) { json_response(422, ['ok' => false, 'error' => 'No products specified']); } +if (!is_array($payments) || count($payments) === 0) { + json_response(422, ['ok' => false, 'error' => 'No payment methods specified']); +} + if ($totalPrice === null || $totalPrice <= 0) { json_response(422, ['ok' => false, 'error' => 'Invalid total price']); } @@ -85,6 +89,30 @@ if ($totalQty <= 0) { json_response(422, ['ok' => false, 'error' => 'No product quantity specified']); } +$resolvedPayments = []; +$paymentTotal = 0.0; +foreach ($payments as $payment) { + if (!is_array($payment) || !isset($payment['paymentMethod'], $payment['amount'])) { + json_response(422, ['ok' => false, 'error' => 'Payment missing paymentMethod or amount']); + } + + $paymentMethodCode = trim((string) $payment['paymentMethod']); + $amount = parse_number($payment['amount']); + if ($paymentMethodCode === '' || $amount === null || $amount <= 0) { + json_response(422, ['ok' => false, 'error' => 'Invalid payment method or amount']); + } + + $resolvedPayments[] = [ + 'paymentMethodCode' => $paymentMethodCode, + 'amount' => round((float) $amount, 2), + ]; + $paymentTotal = round($paymentTotal + (float) $amount, 2); +} + +if (abs($paymentTotal - (float) $totalPrice) > 0.005) { + json_response(422, ['ok' => false, 'error' => 'Payment amounts must equal total price']); +} + try { $env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env')); $pdo = connect_database($env); @@ -92,9 +120,13 @@ try { $locations = get_default_location_ids($pdo); - $paymentMethodId = lookup_method_id($pdo, 'payment_method', $paymentMethodCode); - if ($paymentMethodId === null) { - throw new RuntimeException('Invalid payment method'); + $paymentMethodIds = []; + foreach ($resolvedPayments as $payment) { + $paymentMethodId = lookup_method_id($pdo, 'payment_method', $payment['paymentMethodCode']); + if ($paymentMethodId === null) { + throw new RuntimeException('Invalid payment method'); + } + $paymentMethodIds[] = $paymentMethodId; } $partyId = find_or_create_party($pdo, $billing); @@ -102,7 +134,7 @@ try { $order = create_direct_sales_order($pdo, [ ':party_id' => $partyId, - ':payment_method_id' => $paymentMethodId, + ':payment_method_id' => count($paymentMethodIds) === 1 ? $paymentMethodIds[0] : null, ':amount_net' => $totalPrice, ':total_amount' => $totalPrice, ':order_date' => $orderDateTime->format('Y-m-d H:i:s'), @@ -111,6 +143,18 @@ try { $orderId = (int) $order['id']; $externalRef = (string) $order['external_ref']; + $paymentStmt = $pdo->prepare( + 'INSERT INTO sales_order_payment (sales_order_id, payment_method_id, amount, created_at, updated_at) + VALUES (:sales_order_id, :payment_method_id, :amount, NOW(), NOW())' + ); + foreach ($resolvedPayments as $index => $payment) { + $paymentStmt->execute([ + ':sales_order_id' => $orderId, + ':payment_method_id' => $paymentMethodIds[$index], + ':amount' => $payment['amount'], + ]); + } + $remainingTotal = round((float) $totalPrice, 2); $lineNo = 0; diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index d84b590..fe8a06f 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -401,16 +401,17 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo ''; echo '
'; echo '

Bezahlung

'; - echo '
'; - echo ''; - echo ''; + echo ''; echo ''; + echo ''; + echo ''; echo '
'; + echo ''; + echo ''; echo '
'; echo '
'; echo '

Rechnungsadresse

'; @@ -1001,7 +1002,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const totalPriceInput = form.querySelector('[data-otc-order-total-price]');"; echo " const orderDateInput = form.querySelector('[data-otc-order-date]');"; echo " const orderDateDefaultValue = orderDateInput ? orderDateInput.value : '';"; - echo " const paymentMethodInput = form.querySelector('[data-otc-order-payment-method]');"; + echo " const paymentRows = form.querySelector('[data-otc-payment-rows]');"; + echo " const addPaymentButton = form.querySelector('[data-otc-payment-add]');"; echo " const firstNameInput = form.querySelector('[data-otc-order-first-name]');"; echo " const lastNameInput = form.querySelector('[data-otc-order-last-name]');"; echo " const streetInput = form.querySelector('[data-otc-order-street]');"; @@ -1061,7 +1063,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const isValid = totalQty > 0"; echo " && totalPrice > 0"; echo " && !!getFieldValue(orderDateInput)"; - echo " && !!getFieldValue(paymentMethodInput)"; + echo " && paymentRows.querySelectorAll('[data-otc-payment-row]').length > 0"; echo " && !!getFieldValue(firstNameInput)"; echo " && !!getFieldValue(lastNameInput)"; echo " && !!getFieldValue(streetInput)"; @@ -1075,8 +1077,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " errorMsg = 'Preis muss größer als 0 sein.';"; echo " } else if (!getFieldValue(orderDateInput)) {"; echo " errorMsg = 'Bestelldatum auswählen.';"; - echo " } else if (!getFieldValue(paymentMethodInput)) {"; - echo " errorMsg = 'Zahlungsart auswählen.';"; + echo " } else if (getPaymentTotal() !== totalPrice) {"; + echo " errorMsg = 'Die Zahlungsbeträge müssen dem Gesamtbetrag entsprechen.';"; echo " } else if (!getFieldValue(firstNameInput) || !getFieldValue(lastNameInput) || !getFieldValue(streetInput) || !getFieldValue(houseNumberInput) || !getFieldValue(zipInput) || !getFieldValue(cityInput)) {"; echo " errorMsg = 'Alle Rechnungsadress-Felder ausfüllen.';"; echo " }"; @@ -1091,6 +1093,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " };"; echo " const resetForm = (preserveSuccess = false) => {"; echo " form.reset();"; + echo " Array.from(paymentRows.querySelectorAll('[data-otc-payment-row]')).slice(1).forEach((row) => row.remove());"; echo " clearError();"; echo " if (!preserveSuccess) { clearSuccess(); }"; echo " if (!preserveSuccess) { updateFormState(); }"; @@ -1122,7 +1125,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " toggleOverlay(false);"; echo " }"; echo " });"; - echo " [totalPriceInput, orderDateInput, paymentMethodInput, firstNameInput, lastNameInput, streetInput, houseNumberInput, zipInput, cityInput].forEach((input) => {"; + echo " [totalPriceInput, orderDateInput, firstNameInput, lastNameInput, streetInput, houseNumberInput, zipInput, cityInput].forEach((input) => {"; echo " if (!input) { return; }"; echo " input.addEventListener('input', updateFormState);"; echo " input.addEventListener('change', updateFormState);"; @@ -1131,6 +1134,23 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " input.addEventListener('input', updateFormState);"; echo " input.addEventListener('change', updateFormState);"; echo " });"; + echo " const getPaymentTotal = () => Math.round(Array.from(paymentRows.querySelectorAll('[data-otc-payment-amount]')).reduce((sum, input) => sum + (parseFloat(input.value || '0') || 0), 0) * 100) / 100;"; + echo " const bindPaymentRow = (row) => {"; + echo " row.querySelectorAll('select, input').forEach((input) => { input.addEventListener('input', updateFormState); input.addEventListener('change', updateFormState); });"; + echo " };"; + echo " const paymentOptions = '';"; + echo " const addPaymentRow = () => {"; + echo " const number = paymentRows.querySelectorAll('[data-otc-payment-row]').length + 1;"; + echo " const row = document.createElement('div');"; + echo " row.className = 'sg-form-sections-card__field-group';"; + echo " row.dataset.otcPaymentRow = '';"; + echo " row.innerHTML = '';"; + echo " paymentRows.appendChild(row);"; + echo " bindPaymentRow(row);"; + echo " updateFormState();"; + echo " };"; + echo " addPaymentButton.addEventListener('click', addPaymentRow);"; + echo " bindPaymentRow(paymentRows.querySelector('[data-otc-payment-row]'));"; echo " form.addEventListener('submit', async (event) => {"; echo " event.preventDefault();"; echo " let submissionSucceeded = false;"; @@ -1142,7 +1162,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " products: products,"; echo " totalPrice: parseFloat(totalPriceInput.value || '0') || 0,"; echo " orderDate: orderDateInput ? orderDateInput.value : '',"; - echo " paymentMethod: paymentMethodInput ? paymentMethodInput.value : '',"; + echo " payments: Array.from(paymentRows.querySelectorAll('[data-otc-payment-row]')).map((row) => ({ paymentMethod: row.querySelector('[data-otc-payment-method]').value, amount: parseFloat(row.querySelector('[data-otc-payment-amount]').value || '0') || 0 })),"; echo " billing: {"; echo " firstName: getFieldValue(firstNameInput),"; echo " lastName: getFieldValue(lastNameInput),";