- Create OTC UI form at public/otc/index.php - Add API endpoint public/api/otc-order.php - Extract shared DB connection to includes/db.php - Add migration for OTC products (0006_phase1_otc_products.sql) - Support order_source='direct' with automatic DIR- reference generation - Include billing address capture with default values - Add payment methods cash and paypal for direct sales - Implement stock allocation and inventory management
338 lines
12 KiB
PHP
338 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../includes/db.php';
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
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']);
|
|
}
|
|
|
|
try {
|
|
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $e) {
|
|
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
|
|
}
|
|
|
|
if (!is_array($data)) {
|
|
json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
|
|
}
|
|
|
|
$required = ['products', 'totalPrice', 'paymentMethod', 'billing'];
|
|
foreach ($required as $field) {
|
|
if (!isset($data[$field])) {
|
|
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
|
|
}
|
|
}
|
|
|
|
$products = $data['products'];
|
|
$totalPrice = parse_number($data['totalPrice']);
|
|
$paymentMethodCode = trim((string) $data['paymentMethod']);
|
|
$billing = $data['billing'];
|
|
|
|
if (!is_array($products) || count($products) === 0) {
|
|
json_response(422, ['ok' => false, 'error' => 'No products specified']);
|
|
}
|
|
|
|
if ($totalPrice === null || $totalPrice <= 0) {
|
|
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
|
|
}
|
|
|
|
$totalQty = 0;
|
|
foreach ($products as $product) {
|
|
if (!isset($product['qty']) || !isset($product['title'])) {
|
|
json_response(422, ['ok' => false, 'error' => 'Product missing title or qty']);
|
|
}
|
|
$qty = parse_number($product['qty']);
|
|
if ($qty === null || $qty <= 0) {
|
|
json_response(422, ['ok' => false, 'error' => 'Invalid product quantity']);
|
|
}
|
|
$totalQty += $qty;
|
|
}
|
|
|
|
try {
|
|
$pdo = connect_database();
|
|
$pdo->beginTransaction();
|
|
|
|
$partyName = trim($billing['firstName'] . ' ' . $billing['lastName']);
|
|
$partyName = $partyName === '' ? 'OTC Kunde' : $partyName;
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO party (type, name, status, created_at, updated_at)
|
|
VALUES (\'customer\', :name, \'active\', NOW(), NOW())
|
|
RETURNING id
|
|
');
|
|
$stmt->execute([':name' => $partyName]);
|
|
$partyId = (int) $stmt->fetchColumn();
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO address (
|
|
party_id, type, first_name, last_name, street, house_number, zip, city,
|
|
country_name, created_at, updated_at
|
|
) VALUES (
|
|
:party_id, \'billing\', :first_name, :last_name, :street, :house_number, :zip, :city,
|
|
\'Switzerland\', NOW(), NOW()
|
|
)
|
|
');
|
|
$stmt->execute([
|
|
':party_id' => $partyId,
|
|
':first_name' => trim($billing['firstName'] ?? ''),
|
|
':last_name' => trim($billing['lastName'] ?? ''),
|
|
':street' => trim($billing['street'] ?? ''),
|
|
':house_number' => trim($billing['houseNumber'] ?? ''),
|
|
':zip' => trim($billing['zip'] ?? ''),
|
|
':city' => trim($billing['city'] ?? ''),
|
|
]);
|
|
|
|
$paymentMethodStmt = $pdo->prepare('SELECT id FROM payment_method WHERE code = :code LIMIT 1');
|
|
$paymentMethodStmt->execute([':code' => $paymentMethodCode]);
|
|
$paymentMethodId = $paymentMethodStmt->fetchColumn();
|
|
if ($paymentMethodId === false) {
|
|
throw new RuntimeException('Invalid payment method');
|
|
}
|
|
$paymentMethodId = (int) $paymentMethodId;
|
|
|
|
$orderStmt = $pdo->prepare('
|
|
INSERT INTO sales_order (
|
|
external_ref, party_id, order_source, order_status, payment_status, payment_method_id,
|
|
total_amount, currency, imported_at, created_at, updated_at
|
|
) VALUES (
|
|
:external_ref, :party_id, \'direct\', \'imported\', \'paid\', :payment_method_id,
|
|
:total_amount, \'CHF\', NOW(), NOW(), NOW()
|
|
)
|
|
RETURNING id, external_ref
|
|
');
|
|
$orderStmt->execute([
|
|
':external_ref' => '',
|
|
':party_id' => $partyId,
|
|
':payment_method_id' => $paymentMethodId,
|
|
':total_amount' => $totalPrice,
|
|
]);
|
|
$order = $orderStmt->fetch();
|
|
if ($order === false) {
|
|
throw new RuntimeException('Could not create order');
|
|
}
|
|
$orderId = (int) $order['id'];
|
|
$externalRef = $order['external_ref'];
|
|
|
|
$unitPrice = $totalPrice / $totalQty;
|
|
|
|
$lineInsert = $pdo->prepare('
|
|
INSERT INTO sales_order_line (
|
|
sales_order_id, line_no, raw_external_title, qty, unit_price, line_total,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
:sales_order_id, :line_no, :title, :qty, :unit_price, :line_total,
|
|
NOW(), NOW()
|
|
)
|
|
RETURNING id
|
|
');
|
|
|
|
$locationsStmt = $pdo->query('
|
|
SELECT
|
|
(SELECT id FROM location WHERE type = \'storage\' ORDER BY id LIMIT 1) AS storage_id,
|
|
(SELECT id FROM location WHERE type = \'dispatch\' ORDER BY id LIMIT 1) AS dispatch_id
|
|
');
|
|
$locations = $locationsStmt->fetch();
|
|
$storageId = (int) $locations['storage_id'];
|
|
$dispatchId = (int) $locations['dispatch_id'];
|
|
|
|
foreach ($products as $index => $product) {
|
|
$qty = parse_number($product['qty']);
|
|
$title = trim($product['title']);
|
|
$lineTotal = round($qty * $unitPrice, 2);
|
|
|
|
$lineInsert->execute([
|
|
':sales_order_id' => $orderId,
|
|
':line_no' => $index + 1,
|
|
':title' => $title,
|
|
':qty' => $qty,
|
|
':unit_price' => $unitPrice,
|
|
':line_total' => $lineTotal,
|
|
]);
|
|
$lineId = (int) $lineInsert->fetchColumn();
|
|
|
|
$resolveProductStmt = $pdo->prepare('
|
|
SELECT id FROM product WHERE name ILIKE :pattern ORDER BY id LIMIT 1
|
|
');
|
|
|
|
$pattern = '';
|
|
if (strpos($title, 'Shiitake') !== false) {
|
|
$pattern = '%Shiitake%';
|
|
} elseif (strpos($title, 'Reishi') !== false) {
|
|
$pattern = '%Reishi%';
|
|
} elseif (strpos($title, 'Lion') !== false) {
|
|
$pattern = '%Lion%';
|
|
} elseif (strpos($title, 'Chaga') !== false) {
|
|
$pattern = '%Chaga%';
|
|
}
|
|
|
|
$productId = null;
|
|
if ($pattern !== '') {
|
|
$resolveProductStmt->execute([':pattern' => $pattern]);
|
|
$productId = $resolveProductStmt->fetchColumn();
|
|
}
|
|
if ($productId === false) {
|
|
$productStmt = $pdo->prepare('
|
|
INSERT INTO product (sku, name, status, uom, created_at, updated_at)
|
|
VALUES (:sku, :name, \'active\', \'unit\', NOW(), NOW())
|
|
RETURNING id
|
|
');
|
|
$productStmt->execute([
|
|
':sku' => 'OTC-' . preg_replace('/[^A-Za-z0-9]/', '', substr($title, 0, 20)),
|
|
':name' => $title,
|
|
]);
|
|
$productId = (int) $productStmt->fetchColumn();
|
|
} else {
|
|
$productId = (int) $productId;
|
|
}
|
|
|
|
$sellableItemStmt = $pdo->prepare('
|
|
SELECT id FROM sellable_item WHERE item_code = :item_code ORDER BY id LIMIT 1
|
|
');
|
|
$sellableItemStmt->execute([':item_code' => 'OTC-' . $productId]);
|
|
$sellableItemId = $sellableItemStmt->fetchColumn();
|
|
if ($sellableItemId === false) {
|
|
$sellableInsert = $pdo->prepare('
|
|
INSERT INTO sellable_item (item_code, display_name, status, created_at, updated_at)
|
|
VALUES (:item_code, :display_name, \'active\', NOW(), NOW())
|
|
RETURNING id
|
|
');
|
|
$sellableInsert->execute([
|
|
':item_code' => 'OTC-' . $productId,
|
|
':display_name' => $title,
|
|
]);
|
|
$sellableItemId = (int) $sellableInsert->fetchColumn();
|
|
} else {
|
|
$sellableItemId = (int) $sellableItemId;
|
|
}
|
|
|
|
$updateLineStmt = $pdo->prepare('
|
|
UPDATE sales_order_line
|
|
SET sellable_item_id = :sellable_item_id
|
|
WHERE id = :id
|
|
');
|
|
$updateLineStmt->execute([
|
|
':sellable_item_id' => $sellableItemId,
|
|
':id' => $lineId,
|
|
]);
|
|
|
|
$currentLotStmt = $pdo->prepare('
|
|
SELECT sl.id, COALESCE(v.qty_net, 0) AS qty_net
|
|
FROM stock_lot sl
|
|
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
|
WHERE sl.product_id = :product_id
|
|
AND sl.status = \'current\'
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
');
|
|
$currentLotStmt->execute([':product_id' => $productId]);
|
|
$lot = $currentLotStmt->fetch();
|
|
if ($lot === false || (float) $lot['qty_net'] < $qty) {
|
|
$closeLotStmt = $pdo->prepare('
|
|
UPDATE stock_lot
|
|
SET status = \'closed\', updated_at = NOW()
|
|
WHERE id = :id
|
|
');
|
|
$closeLotStmt->execute([':id' => (int) $lot['id']]);
|
|
|
|
$openLotStmt = $pdo->prepare('
|
|
SELECT id FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = \'open\'
|
|
ORDER BY id LIMIT 1
|
|
FOR UPDATE
|
|
');
|
|
$openLotStmt->execute([':product_id' => $productId]);
|
|
$openLotId = $openLotStmt->fetchColumn();
|
|
if ($openLotId === false) {
|
|
$createOpenLotStmt = $pdo->prepare('
|
|
INSERT INTO stock_lot (product_id, status, created_at, updated_at)
|
|
VALUES (:product_id, \'open\', NOW(), NOW())
|
|
RETURNING id
|
|
');
|
|
$createOpenLotStmt->execute([':product_id' => $productId]);
|
|
$openLotId = (int) $createOpenLotStmt->fetchColumn();
|
|
} else {
|
|
$openLotId = (int) $openLotId;
|
|
}
|
|
|
|
$promoteLotStmt = $pdo->prepare('
|
|
UPDATE stock_lot
|
|
SET status = \'current\',
|
|
lot_number = COALESCE(lot_number, \'OTC-\' || :product_id || \'-\' || :lot_id),
|
|
updated_at = NOW()
|
|
WHERE id = :lot_id
|
|
');
|
|
$promoteLotStmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $openLotId,
|
|
]);
|
|
|
|
$lotId = $openLotId;
|
|
} else {
|
|
$lotId = (int) $lot['id'];
|
|
}
|
|
|
|
$stockMoveStmt = $pdo->prepare('
|
|
INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type,
|
|
move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, \'out\',
|
|
NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id
|
|
');
|
|
$stockMoveStmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $storageId,
|
|
':to_location_id' => $dispatchId,
|
|
':qty' => $qty,
|
|
':note' => 'OTC order ' . $orderId . ': ' . $title,
|
|
]);
|
|
$stockMoveId = (int) $stockMoveStmt->fetchColumn();
|
|
|
|
$allocationStmt = $pdo->prepare('
|
|
INSERT INTO sales_order_line_lot_allocation (
|
|
sales_order_line_id, product_id, lot_id, qty, allocation_status,
|
|
stock_move_id, created_at, updated_at
|
|
) VALUES (
|
|
:line_id, :product_id, :lot_id, :qty, \'allocated\',
|
|
:stock_move_id, NOW(), NOW()
|
|
)
|
|
');
|
|
$allocationStmt->execute([
|
|
':line_id' => $lineId,
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':qty' => $qty,
|
|
':stock_move_id' => $stockMoveId,
|
|
]);
|
|
}
|
|
|
|
$pdo->commit();
|
|
|
|
json_response(201, [
|
|
'ok' => true,
|
|
'orderId' => $orderId,
|
|
'externalRef' => $externalRef,
|
|
'partyId' => $partyId,
|
|
'message' => 'OTC order created successfully',
|
|
]);
|
|
} catch (Exception $e) {
|
|
if (isset($pdo) && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
json_response(500, [
|
|
'ok' => false,
|
|
'error' => 'Internal server error: ' . $e->getMessage(),
|
|
]);
|
|
} |