Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
1184 lines
50 KiB
PHP
1184 lines
50 KiB
PHP
<?php
|
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
|
require_once __DIR__ . '/DocumentValidator.php';
|
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
|
|
|
/**
|
|
* OrderManager
|
|
*
|
|
* Handles all read and write operations for sales orders (td_order)
|
|
* and their downstream stock-out effects on td_stock_<warehouse_id> tables.
|
|
*
|
|
* Method order:
|
|
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
|
* saveOrder, confirmOrder, cancelOrder,
|
|
* updateShippingTracking
|
|
*
|
|
* Key design decisions:
|
|
* - Order items are stored as a JSON array in td_order.items.
|
|
* No separate child table exists. SKU-level reporting is served by
|
|
* td_stock_* rows (source='order') rather than querying items JSON.
|
|
* - confirmOrder() picks bins via FIFO — oldest approved stock-in row
|
|
* still bin-occupied, ordered by td_stock_<warehouse_id>.date ASC.
|
|
* - confirmOrder() creates stock-out rows with status=0 (draft).
|
|
* Warehouse staff approve them via the existing approve_stock.php
|
|
* engine, which handles bin release and balance adjustment.
|
|
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
|
|
* stock-out rows (status → -1) across all td_stock_* tables. If linked
|
|
* stock-out rows were already approved, their bin and balance effects are
|
|
* reversed before the rows are cancelled.
|
|
* Cancellation is blocked if any active invoice (status != 4 / void) or
|
|
* active return (status != -1 / cancelled) is linked to the order.
|
|
* - Cancel logic stays in the order domain, but uses WarehouseManager for
|
|
* the same bin and balance reversal rules as manual stock-out deletion.
|
|
*
|
|
* Note: Write methods do NOT manage their own DB transactions.
|
|
* Callers must wrap multi-step operations inside dbTransaction().
|
|
*
|
|
* Security: All SQL uses PDO prepared statements with bound parameters.
|
|
* Dynamic stock table names are derived only from md_warehouse.id.
|
|
*/
|
|
class OrderManager {
|
|
|
|
private PDO $pdo;
|
|
private int $company_id;
|
|
|
|
public function __construct(PDO $pdo, int $company_id) {
|
|
$this->pdo = $pdo;
|
|
$this->company_id = $company_id;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Private helpers
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a standard audit log entry array for append-to-JSON log columns.
|
|
*
|
|
* @param string $action Short label e.g. 'create', 'update', 'confirm', 'cancel'.
|
|
* @return array
|
|
*/
|
|
private function buildLogEntry(string $action): array {
|
|
return [
|
|
'user_id' => $_SESSION['login_user_id'] ?? null,
|
|
'dt' => date('Y-m-d H:i:s'),
|
|
'login' => isset($_SESSION['otpTime'])
|
|
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
|
: null,
|
|
'action' => $action,
|
|
];
|
|
}
|
|
|
|
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
|
{
|
|
if ($warehouse_id <= 0) {
|
|
throw new Exception("Invalid warehouse id.");
|
|
}
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id FROM md_warehouse
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
|
if (!$sth->fetchColumn()) {
|
|
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
|
}
|
|
|
|
return 'td_stock_' . $warehouse_id;
|
|
}
|
|
|
|
/**
|
|
* FIFO bin pick — find the oldest approved stock-in row for a SKU
|
|
* in a given warehouse that is still bin-occupied.
|
|
*
|
|
* "Oldest" = smallest date value among status=1 stock-in rows
|
|
* that have an md_bin row pointing back to them (td_stock_id IS set).
|
|
*
|
|
* @param int $warehouse_id
|
|
* @param string $product_sku
|
|
* @return array|null Full td_stock row + zone/aisle/bin from md_bin,
|
|
* or null if no available stock in this warehouse.
|
|
*/
|
|
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
|
|
{
|
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT s.*, r.zone, r.aisle, r.bin, l.expiry_date,
|
|
(s.`in` - COALESCE((
|
|
SELECT SUM(o.`out`)
|
|
FROM `{$table}` o
|
|
WHERE o.company_id = s.company_id
|
|
AND o.ref_id = s.id
|
|
AND o.`out` > 0
|
|
AND o.status != -1
|
|
), 0)) AS available_qty
|
|
FROM `{$table}` s
|
|
INNER JOIN md_bin r
|
|
ON r.company_id = s.company_id
|
|
AND r.warehouse = :warehouse_id
|
|
AND r.td_stock_id = s.id
|
|
AND r.product_sku IS NOT NULL
|
|
LEFT JOIN md_lot l
|
|
ON l.company_id = s.company_id
|
|
AND l.product_sku = s.product_sku
|
|
AND l.lot_number = s.lot_number
|
|
AND s.lot_number != ''
|
|
WHERE s.company_id = :company_id
|
|
AND s.product_sku = :product_sku
|
|
AND s.`in` > 0
|
|
AND s.status = 1
|
|
HAVING available_qty > 0
|
|
ORDER BY s.date ASC, s.id ASC
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':warehouse_id' => $warehouse_id,
|
|
':product_sku' => $product_sku,
|
|
]);
|
|
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Generate the next sequential order number in ORD-YYYYMMDD-XXXX format.
|
|
*
|
|
* Finds the highest sequence number already used today and increments by 1.
|
|
* Thread-safe enough for single-company use; wrap in transaction if needed.
|
|
*
|
|
* @return string e.g. "ORD-20260502-0001"
|
|
*/
|
|
private function generateOrderNumber(array $data = []): string
|
|
{
|
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
|
->resolveNumber($data, 'sales_order', 'td_order', 'order_number');
|
|
}
|
|
|
|
/**
|
|
* Derive fulfillment from linked stock-out rows and tracking.
|
|
*
|
|
* 0 = no fulfillment yet
|
|
* 1 = picking (at least one linked stock-out row is draft)
|
|
* 2 = packed (all linked stock-out rows are approved, no tracking)
|
|
* 3 = shipped (all linked stock-out rows are approved, tracking exists)
|
|
*/
|
|
private function deriveFulfillmentStatus(array $order): int
|
|
{
|
|
$status = (int)($order['status'] ?? 0);
|
|
if ($status < 1) return 0;
|
|
|
|
$order_id = (int)($order['id'] ?? 0);
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT warehouse_id, stock_out_id FROM td_order_item
|
|
WHERE order_id = :order_id AND company_id = :company_id"
|
|
);
|
|
$sth->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
|
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$linked_count = 0;
|
|
$draft_count = 0;
|
|
|
|
foreach ($items as $item) {
|
|
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
|
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
|
if ($warehouse_id <= 0 || $stock_out_id <= 0) continue;
|
|
|
|
$table = 'td_stock_' . $warehouse_id;
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT status FROM `{$table}`
|
|
WHERE company_id = :company_id
|
|
AND id = :id
|
|
AND type = 'out'
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':id' => $stock_out_id,
|
|
]);
|
|
|
|
$stock_status = $sth->fetchColumn();
|
|
if ($stock_status === false) continue;
|
|
|
|
$linked_count++;
|
|
if ((int)$stock_status === 0) {
|
|
$draft_count++;
|
|
}
|
|
}
|
|
|
|
if ($linked_count === 0) return 0;
|
|
if ($draft_count > 0) return 1;
|
|
|
|
return trim((string)($order['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2;
|
|
}
|
|
|
|
private function syncOrderItems(int $order_id, array $items): void
|
|
{
|
|
$this->pdo->prepare(
|
|
"DELETE FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id"
|
|
)->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"INSERT INTO td_order_item
|
|
(company_id, order_id, item_id, product_sku, product_name,
|
|
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, lot_number, invoiced_qty)
|
|
VALUES
|
|
(:company_id, :order_id, :item_id, :product_sku, :product_name,
|
|
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :lot_number, 0)"
|
|
);
|
|
foreach ($items as $pos => $item) {
|
|
$sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':order_id' => $order_id,
|
|
':item_id' => $pos + 1,
|
|
':product_sku' => $item['product_sku'] ?? '',
|
|
':product_name' => $item['product_name'] ?? '',
|
|
':quantity' => (float)($item['quantity'] ?? 0),
|
|
':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0),
|
|
':total_price' => (float)($item['total_price'] ?? 0),
|
|
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
|
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
|
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
|
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
|
|
':lot_number' => $item['lot_number'] ?? '',
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// TRANSACTION BASIS — Read
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return all orders for the company, ordered by created_at DESC.
|
|
*
|
|
* Joins md_contact for contact_name display.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getOrderList(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT o.*,
|
|
COALESCE(c.contact_name, '') AS contact_name,
|
|
(SELECT COUNT(*) FROM td_order_item i
|
|
WHERE i.company_id = o.company_id
|
|
AND i.order_id = o.id) AS item_count
|
|
FROM td_order o
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = o.company_id
|
|
AND c.id = o.contact_id
|
|
WHERE o.company_id = :company_id
|
|
ORDER BY o.order_date DESC, o.id DESC"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id]);
|
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
foreach ($rows as &$row) {
|
|
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
|
}
|
|
unset($row);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single order row by its primary key.
|
|
*
|
|
* Returns the row with items decoded as a PHP array.
|
|
*
|
|
* @param int $id td_order.id
|
|
* @return array|false
|
|
*/
|
|
public function getOrderById(int $id): array|false
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT o.*,
|
|
COALESCE(c.contact_name, '') AS contact_name
|
|
FROM td_order o
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = o.company_id
|
|
AND c.id = o.contact_id
|
|
WHERE o.company_id = :company_id
|
|
AND o.id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row) return false;
|
|
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT * FROM td_order_item
|
|
WHERE order_id = :order_id AND company_id = :company_id
|
|
ORDER BY item_id"
|
|
);
|
|
$sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]);
|
|
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
|
return $row;
|
|
}
|
|
|
|
public function getActiveInvoiceId(int $order_id): int
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id FROM td_invoice
|
|
WHERE company_id = :company_id
|
|
AND order_id = :order_id
|
|
AND doc_type = 'invoice'
|
|
AND status != 4
|
|
ORDER BY id DESC
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
|
return (int)$sth->fetchColumn();
|
|
}
|
|
|
|
public function getReturnableItems(array $order): array
|
|
{
|
|
$order_id = (int)($order['id'] ?? 0);
|
|
$order_items = $order['items'] ?? [];
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT ri.item_id, ri.quantity
|
|
FROM td_return_item ri
|
|
JOIN td_return r ON r.id = ri.return_id AND r.company_id = ri.company_id
|
|
WHERE ri.company_id = :company_id
|
|
AND r.order_id = :order_id
|
|
AND r.status = 1"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
|
$returned_qty = [];
|
|
|
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $item) {
|
|
$item_id = (int)($item['item_id'] ?? -1);
|
|
if ($item_id < 0) continue;
|
|
$returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity'];
|
|
}
|
|
|
|
$returnable = [];
|
|
foreach ($order_items as $i => $item) {
|
|
$already_returned = $returned_qty[$i] ?? 0;
|
|
$remaining = (float)$item['quantity'] - $already_returned;
|
|
if ($remaining <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
|
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
|
|
|
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
|
|
$table = 'td_stock_' . $stock_out_warehouse_id;
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id, status, zone, aisle, bin, lot_number, serial_number
|
|
FROM `{$table}`
|
|
WHERE company_id = :company_id
|
|
AND id = :id
|
|
AND type = 'out'
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':id' => $stock_out_id,
|
|
]);
|
|
$stock_out = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($stock_out) {
|
|
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
|
|
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
|
|
$item['bin'] = $stock_out['bin'] ?? ($item['bin'] ?? '');
|
|
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
|
|
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
|
|
$item['stock_out_status'] = (int)$stock_out['status'];
|
|
}
|
|
}
|
|
|
|
$returnable[] = array_merge($item, [
|
|
'item_id' => $i,
|
|
'original_qty' => (float)$item['quantity'],
|
|
'returned_qty' => $already_returned,
|
|
'returnable_qty' => $remaining,
|
|
'stock_out_warehouse_id' => $stock_out_warehouse_id,
|
|
'stock_out_status' => (int)($item['stock_out_status'] ?? 0),
|
|
]);
|
|
}
|
|
|
|
return $returnable;
|
|
}
|
|
|
|
/**
|
|
* Mark an accepted quotation as converted once an order has been created
|
|
* from it.
|
|
*
|
|
* The link itself lives on the order (td_order.source = 'quotation',
|
|
* source_id = quotation id), which saveOrder() has already written and
|
|
* QuotationManager::getById() joins on. This used to also write
|
|
* td_quotation.order_id — a column that has never existed — so saving an
|
|
* order with source=quotation failed with "Unknown column" and rolled the
|
|
* new order back with it.
|
|
*/
|
|
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
|
|
{
|
|
$this->pdo->prepare(
|
|
"UPDATE td_quotation SET status = 5
|
|
WHERE id = :id AND company_id = :cid AND status = 2"
|
|
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
public function assertRevenueOrderEditable(int $order_id): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT source, status FROM td_order
|
|
WHERE id = :id AND company_id = :cid
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([':id' => $order_id, ':cid' => $this->company_id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row || !in_array($row['source'], ['revenue', 'quotation'], true)) {
|
|
throw new Exception('Revenue sales order not found.');
|
|
}
|
|
if ((int)$row['status'] !== -2) {
|
|
throw new Exception('Only pending sales orders can be edited.');
|
|
}
|
|
}
|
|
|
|
public function assertRevenueOrder(int $order_id): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT source FROM td_order
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
|
$source = (string)$sth->fetchColumn();
|
|
|
|
if (!in_array($source, ['revenue', 'quotation'], true)) {
|
|
throw new Exception("This is not a Revenue sales order.");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// TRANSACTION BASIS — Write
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Insert a new order (draft) or update metadata on an existing one.
|
|
*
|
|
* Insert flow (id = 0):
|
|
* - Generates order_number.
|
|
* - Stores items as JSON array.
|
|
* - Calculates grand_total from items + adjustments.
|
|
* - Sets status = 0 (draft), created_at = now().
|
|
*
|
|
* Update flow (id > 0):
|
|
* - Only allowed while status = 0 (draft).
|
|
* - Updates contact, date, items, totals, notes.
|
|
*
|
|
* Must be called inside dbTransaction() by the caller.
|
|
*
|
|
* @param array $data Keys: id, contact_id, order_date, items (array),
|
|
* discount, tax, shipping_fee, shipping_tracking_number, notes.
|
|
* @param array $logging Audit entry to append to log column.
|
|
* @return int New td_order.id on insert, 0 on update.
|
|
* @throws Exception If updating a non-draft order.
|
|
*/
|
|
public function saveOrder(array $data, array $logging): int
|
|
{
|
|
$id = (int)($data['id'] ?? 0);
|
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
|
|
$data['items'] = $items;
|
|
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
|
|
if ($id === 0 || array_key_exists('department_id', $data)) {
|
|
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
|
}
|
|
|
|
// Calculate totals from items
|
|
$subtotal = array_reduce($items, fn($carry, $item) =>
|
|
$carry + (float)($item['total_price'] ?? 0), 0.0
|
|
);
|
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
|
|
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
|
if (abs($tax_adjustment) > 0.30) {
|
|
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
|
}
|
|
$tax = round(array_reduce($items, fn($carry, $item) =>
|
|
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
|
), 2) + $tax_adjustment;
|
|
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
|
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
|
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
|
|
|
if ($id > 0) {
|
|
|
|
// Fetch existing row to check status and load log
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT status, department_id, `log` FROM td_order
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row) {
|
|
throw new Exception("Order not found.");
|
|
}
|
|
|
|
// Pages without a department field (Revenue SO) must not wipe the stored one
|
|
$department_id = array_key_exists('department_id', $data)
|
|
? (int)$data['department_id']
|
|
: (int)$row['department_id'];
|
|
$cur_status = (int)$row['status'];
|
|
if ($cur_status !== 0 && $cur_status !== -2) {
|
|
throw new Exception("Only draft or pending orders can be edited.");
|
|
}
|
|
|
|
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
|
$log[] = $logging;
|
|
|
|
// Auto-promote Pending → Draft when all items have a warehouse assigned
|
|
$new_status = $cur_status;
|
|
if ($cur_status === -2) {
|
|
$all_have_warehouse = !empty($items) && array_reduce(
|
|
$items,
|
|
fn($ok, $item) => $ok && (int)($item['warehouse_id'] ?? 0) > 0,
|
|
true
|
|
);
|
|
if ($all_have_warehouse) {
|
|
$new_status = 0;
|
|
}
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order SET
|
|
contact_id = :contact_id,
|
|
department_id = :department_id,
|
|
order_date = :order_date,
|
|
subtotal = :subtotal,
|
|
discount = :discount,
|
|
tax = :tax,
|
|
tax_adjustment = :tax_adjustment,
|
|
shipping_fee = :shipping_fee,
|
|
shipping_tracking_number = :shipping_tracking_number,
|
|
grand_total = :grand_total,
|
|
notes = :notes,
|
|
status = :status,
|
|
`log` = :log
|
|
WHERE id = :id AND company_id = :company_id"
|
|
)->execute([
|
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
|
':department_id' => $department_id,
|
|
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
|
':subtotal' => $subtotal,
|
|
':discount' => $discount,
|
|
':tax' => $tax,
|
|
':tax_adjustment' => $tax_adjustment,
|
|
':shipping_fee' => $shipping_fee,
|
|
':shipping_tracking_number' => $tracking_no,
|
|
':grand_total' => $grand_total,
|
|
':notes' => $data['notes'] ?? '',
|
|
':status' => $new_status,
|
|
':log' => json_encode($log),
|
|
':id' => $id,
|
|
':company_id' => $this->company_id,
|
|
]);
|
|
|
|
$this->syncOrderItems($id, $items);
|
|
return 0;
|
|
|
|
} else {
|
|
|
|
$log = [$logging];
|
|
$source = trim((string)($data['source'] ?? ''));
|
|
$source_id = (int)($data['source_id'] ?? 0);
|
|
|
|
// Direct WMS orders start as Draft; sourced orders wait for WMS assignment.
|
|
$init_status = $source !== '' ? -2 : 0;
|
|
|
|
$this->pdo->prepare(
|
|
"INSERT INTO td_order
|
|
(company_id, uuid, source_id, `source`, order_number, contact_id, department_id, order_date,
|
|
status, subtotal, discount, tax, tax_adjustment,
|
|
shipping_fee, shipping_tracking_number, grand_total, notes, `log`, created_at)
|
|
VALUES
|
|
(:company_id, :uuid, :source_id, :source, :order_number, :contact_id, :department_id, :order_date,
|
|
:status, :subtotal, :discount, :tax, :tax_adjustment,
|
|
:shipping_fee, :shipping_tracking_number, :grand_total, :notes, :log, :created_at)"
|
|
)->execute([
|
|
':company_id' => $this->company_id,
|
|
':uuid' => bin2hex(random_bytes(16)),
|
|
':source_id' => $source_id,
|
|
':source' => $source,
|
|
':order_number' => $this->generateOrderNumber($data),
|
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
|
':department_id' => (int)($data['department_id'] ?? 0),
|
|
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
|
':status' => $init_status,
|
|
':subtotal' => $subtotal,
|
|
':discount' => $discount,
|
|
':tax' => $tax,
|
|
':tax_adjustment' => $tax_adjustment,
|
|
':shipping_fee' => $shipping_fee,
|
|
':shipping_tracking_number' => $tracking_no,
|
|
':grand_total' => $grand_total,
|
|
':notes' => $data['notes'] ?? '',
|
|
':log' => json_encode($log),
|
|
':created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
$new_id = (int)$this->pdo->lastInsertId();
|
|
$this->syncOrderItems($new_id, $items);
|
|
return $new_id;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm a draft order — create stock-out rows (status=0) for each item
|
|
* using FIFO bin selection, then advance the order to status=1.
|
|
*
|
|
* Flow per item:
|
|
* 1. pickFifoRack() — find oldest bin-occupied stock-in row for the SKU.
|
|
* 2. INSERT into td_stock_<warehouse_id> with type='out', status=0,
|
|
* source='order', source_id=order_id.
|
|
* 3. Write back stock_out_id into the item object in td_order.items.
|
|
*
|
|
* After all items are processed:
|
|
* 4. UPDATE td_order.items with stock_out_id values written back.
|
|
* 5. UPDATE td_order.status = 1 (confirmed).
|
|
*
|
|
* Bin release and balance adjustment are intentionally deferred —
|
|
* they happen when warehouse staff approve the stock-out rows via
|
|
* the existing approve_stock.php engine (StockManager::approveStock).
|
|
*
|
|
* Must be called inside dbTransaction() by the caller.
|
|
*
|
|
* @param int $order_id td_order.id to confirm.
|
|
* @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}.
|
|
* @param array $logging Audit entry appended to td_order.log.
|
|
* @throws Exception If order not found, not in draft status, or any item
|
|
* has no available FIFO bin in its assigned warehouse.
|
|
*/
|
|
public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT * FROM td_order
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
|
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$order) {
|
|
throw new Exception("Order not found.");
|
|
}
|
|
if ((int)$order['status'] !== 0) {
|
|
throw new Exception("Only draft orders can be confirmed.");
|
|
}
|
|
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT * FROM td_order_item
|
|
WHERE order_id = :order_id AND company_id = :company_id
|
|
ORDER BY item_id"
|
|
);
|
|
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
|
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
if (empty($items)) {
|
|
throw new Exception("Cannot confirm an order with no items.");
|
|
}
|
|
|
|
// ── Pre-flight: validate ALL items before creating any rows ───────
|
|
$preflight_errors = [];
|
|
foreach ($items as $i => $item) {
|
|
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
|
$product_sku = $item['product_sku'] ?? '';
|
|
$quantity = (float)($item['quantity'] ?? 0);
|
|
$name = $item['product_name'] ?? $product_sku;
|
|
|
|
if (!$warehouse_id || !$product_sku) {
|
|
$preflight_errors[] = "Item #{$i}: missing warehouse or SKU.";
|
|
continue;
|
|
}
|
|
if ($quantity <= 0) {
|
|
$preflight_errors[] = "Item #{$i}: quantity must be greater than zero.";
|
|
continue;
|
|
}
|
|
|
|
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
|
if (!$bin_stock) {
|
|
$preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse.";
|
|
continue;
|
|
}
|
|
|
|
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
|
|
if ($quantity - $available_qty > 0.000001) {
|
|
$preflight_errors[] =
|
|
"Insufficient stock for \"{$name}\": " .
|
|
"{$available_qty} available, {$quantity} requested.";
|
|
}
|
|
|
|
$expiry = $bin_stock['expiry_date'] ?? null;
|
|
if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) {
|
|
$lot = $bin_stock['lot_number'] ?? '';
|
|
$preflight_errors[] =
|
|
"Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked.";
|
|
}
|
|
}
|
|
|
|
if (!empty($preflight_errors)) {
|
|
throw new Exception(implode("\n", $preflight_errors));
|
|
}
|
|
|
|
// Validate all warehouse IDs in one query before entering the item loop.
|
|
$itemWarehouseIds = array_values(array_unique(array_filter(
|
|
array_map(fn($item) => (int)($item['warehouse_id'] ?? 0), $items),
|
|
fn($id) => $id > 0
|
|
)));
|
|
if (!empty($itemWarehouseIds)) {
|
|
$placeholders = implode(',', array_fill(0, count($itemWarehouseIds), '?'));
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)"
|
|
);
|
|
$sth->execute([$this->company_id, ...$itemWarehouseIds]);
|
|
$validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
|
|
} else {
|
|
$validWarehouseIds = [];
|
|
}
|
|
|
|
foreach ($items as $i => &$item) {
|
|
|
|
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
|
$product_sku = $item['product_sku'] ?? '';
|
|
$quantity = (float)($item['quantity'] ?? 0);
|
|
|
|
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
|
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
|
|
|
|
if (!isset($validWarehouseIds[$warehouse_id])) {
|
|
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
|
}
|
|
$table = 'td_stock_' . $warehouse_id;
|
|
$item_uuid = $uuid . '_' . $i;
|
|
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
|
|
|
|
$this->pdo->prepare(
|
|
"INSERT INTO `{$table}`
|
|
(uuid, company_id, `date`, product_sku, `out`,
|
|
zone, aisle, bin,
|
|
contact_id, `description`, `log`, `type`,
|
|
ref_id, lot_number, serial_number,
|
|
source, source_id, price, status)
|
|
VALUES
|
|
(:uuid, :company_id, :date, :product_sku, :quantity,
|
|
:zone, :aisle, :bin,
|
|
:contact_id, :description, :log, 'out',
|
|
:ref_id, :lot_number, :serial_number,
|
|
'order', :source_id, :price, 0)"
|
|
)->execute([
|
|
':uuid' => $item_uuid,
|
|
':company_id' => $this->company_id,
|
|
':date' => date('Y-m-d H:i:s'),
|
|
':product_sku' => $product_sku,
|
|
':quantity' => $quantity,
|
|
':zone' => $bin_stock['zone'],
|
|
':aisle' => $bin_stock['aisle'],
|
|
':bin' => $bin_stock['bin'],
|
|
':contact_id' => (int)$order['contact_id'],
|
|
':description' => $order['order_number'],
|
|
':log' => json_encode($item_log),
|
|
':ref_id' => (int)$bin_stock['id'],
|
|
':lot_number' => $bin_stock['lot_number'] ?? '',
|
|
':serial_number' => $bin_stock['serial_number'] ?? '',
|
|
':source_id' => $order_id,
|
|
':price' => (float)($item['unit_price'] ?? 0),
|
|
]);
|
|
|
|
// Write bin context + stock_out_id back into the item object
|
|
$item['zone'] = $bin_stock['zone'];
|
|
$item['aisle'] = $bin_stock['aisle'];
|
|
$item['bin'] = $bin_stock['bin'];
|
|
$item['lot_number'] = $bin_stock['lot_number'] ?? '';
|
|
$item['serial_number'] = $bin_stock['serial_number'] ?? '';
|
|
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
|
|
|
|
// Auto-approve: immediately release bin + adjust balance
|
|
if ($auto_approve) {
|
|
$stock = new StockManager($this->pdo, $this->company_id);
|
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
|
$stock->approveStock($item['stock_out_id'], $warehouse_id, 'out', $whMgmt);
|
|
}
|
|
}
|
|
unset($item); // break reference
|
|
|
|
// ── Update order: status=1 ───────────────────────────────────────
|
|
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
|
$log[] = array_merge($logging, ['action' => 'confirm']);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order SET
|
|
status = 1,
|
|
`log` = :log
|
|
WHERE id = :id AND company_id = :company_id"
|
|
)->execute([
|
|
':log' => json_encode($log),
|
|
':id' => $order_id,
|
|
':company_id' => $this->company_id,
|
|
]);
|
|
|
|
$upd = $this->pdo->prepare(
|
|
"UPDATE td_order_item SET
|
|
stock_out_id = :stock_out_id,
|
|
lot_number = :lot_number,
|
|
warehouse_id = :warehouse_id
|
|
WHERE order_id = :order_id AND item_id = :item_id AND company_id = :company_id"
|
|
);
|
|
foreach ($items as $item) {
|
|
$upd->execute([
|
|
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
|
|
':lot_number' => $item['lot_number'] ?? '',
|
|
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
|
':order_id' => $order_id,
|
|
':item_id' => (int)($item['item_id'] ?? 0),
|
|
':company_id' => $this->company_id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel an order and soft-delete all linked stock-out rows (status → -1).
|
|
*
|
|
* Business rule:
|
|
* An order can be cancelled (Draft or Confirmed) as long as no active
|
|
* downstream documents exist. For invoices, active means not voided
|
|
* (status != 4). For returns, active means not cancelled (status != -1).
|
|
*
|
|
* Downstream documents that block cancellation:
|
|
* - td_invoice where order_id = $order_id AND status != 4
|
|
* - td_return where order_id = $order_id AND status != -1
|
|
*
|
|
* Stock-out rows are children of the order — they follow the parent.
|
|
* On cancel, approved rows first re-occupy the original source bin and
|
|
* reverse the stock-out balance. Then ALL stock-out rows linked to this
|
|
* order (any status except already -1) are soft-deleted (status → -1)
|
|
* across all td_stock_* tables.
|
|
*
|
|
* Must be called inside dbTransaction() by the caller.
|
|
*
|
|
* @param int $order_id td_order.id to cancel.
|
|
* @param array $logging Audit entry appended to td_order.log.
|
|
* @throws Exception
|
|
*/
|
|
public function cancelOrder(int $order_id, array $logging): void
|
|
{
|
|
// ── Load order ────────────────────────────────────────────────────
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT * FROM td_order
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
|
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$order) {
|
|
throw new Exception("Order not found.");
|
|
}
|
|
|
|
$status = (int)$order['status'];
|
|
|
|
if ($status === -1) {
|
|
throw new Exception("Order is already cancelled.");
|
|
}
|
|
// Pending orders (-2) have no stock rows — skip the stock reversal loop below
|
|
|
|
// ── Guard: no active invoice ──────────────────────────────────────
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_invoice
|
|
WHERE company_id = :company_id
|
|
AND order_id = :order_id
|
|
AND status != 4"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
|
if ((int)$sth->fetchColumn() > 0) {
|
|
throw new Exception(
|
|
"Cannot cancel — this order has an active invoice. " .
|
|
"Please void the invoice first."
|
|
);
|
|
}
|
|
|
|
// ── Guard: no active return ───────────────────────────────────────
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_return
|
|
WHERE company_id = :company_id
|
|
AND order_id = :order_id
|
|
AND status != -1"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
|
if ((int)$sth->fetchColumn() > 0) {
|
|
throw new Exception(
|
|
"Cannot cancel — this order has an active return. " .
|
|
"Please cancel the return first."
|
|
);
|
|
}
|
|
|
|
// ── Reverse approved stock-out side effects, then soft-delete rows ─
|
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
|
$tables = $whMgmt->getStockTables();
|
|
|
|
foreach ($tables as $table) {
|
|
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) {
|
|
continue;
|
|
}
|
|
|
|
$warehouse_id = (int)$matches[1];
|
|
|
|
$row_sth = $this->pdo->prepare(
|
|
"SELECT id, product_sku, `out`, zone, aisle, bin, ref_id, status, `date`
|
|
FROM `{$table}`
|
|
WHERE company_id = :company_id
|
|
AND source = 'order'
|
|
AND source_id = :order_id
|
|
AND type = 'out'
|
|
AND status != -1"
|
|
);
|
|
$row_sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':order_id' => $order_id,
|
|
]);
|
|
$rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
foreach ($rows as $row) {
|
|
if ((int)$row['status'] !== 1) {
|
|
continue;
|
|
}
|
|
|
|
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation');
|
|
|
|
$whMgmt->occupyBin(
|
|
$warehouse_id,
|
|
(string)$row['zone'],
|
|
(string)$row['aisle'],
|
|
(string)$row['bin'],
|
|
(string)$row['product_sku'],
|
|
(int)$row['ref_id']
|
|
);
|
|
$whMgmt->adjustBalance(
|
|
'out', $warehouse_id, (string)$row['product_sku'],
|
|
(float)$row['out'], 0,
|
|
(int)$row['id'], 'order', $order_id
|
|
);
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE `{$table}` SET status = -1
|
|
WHERE company_id = :company_id
|
|
AND source = 'order'
|
|
AND source_id = :order_id
|
|
AND status != -1"
|
|
)->execute([
|
|
':company_id' => $this->company_id,
|
|
':order_id' => $order_id,
|
|
]);
|
|
}
|
|
|
|
// ── Release converted_qty on source quotation ─────────────────────────
|
|
$source = (string)($order['source'] ?? '');
|
|
$source_id = (int)($order['source_id'] ?? 0);
|
|
|
|
if ($source === 'quotation' && $source_id > 0) {
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT item_id, quantity FROM td_order_item
|
|
WHERE order_id = :order_id AND company_id = :company_id
|
|
ORDER BY item_id"
|
|
);
|
|
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
|
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
|
$dec = $this->pdo->prepare(
|
|
"UPDATE td_quotation_item
|
|
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
|
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
|
|
);
|
|
foreach ($items as $item) {
|
|
$dec->execute([
|
|
':qty' => (float)($item['quantity'] ?? 0),
|
|
':qid' => $source_id,
|
|
':item_id' => (int)($item['item_id'] ?? 0),
|
|
':cid' => $this->company_id,
|
|
]);
|
|
}
|
|
// Revert quotation from Converted back to Accepted if it was fully done
|
|
$this->pdo->prepare(
|
|
"UPDATE td_quotation SET status = 2
|
|
WHERE id = :id AND company_id = :cid AND status = 5"
|
|
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
// ── Cancel the order ──────────────────────────────────────────────
|
|
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
|
$log[] = array_merge($logging, ['action' => 'cancel']);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order SET
|
|
status = -1,
|
|
`log` = :log
|
|
WHERE id = :id AND company_id = :company_id"
|
|
)->execute([
|
|
':log' => json_encode($log),
|
|
':id' => $order_id,
|
|
':company_id' => $this->company_id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Soft-delete an order by negating company_id on the header, items, and all
|
|
* linked stock-out rows. Approved stock-out side effects are reversed first.
|
|
* Blocked if any invoice or return (not yet soft-deleted) exists for this order.
|
|
*/
|
|
public function softDelete(int $id): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT * FROM td_order WHERE company_id = :cid AND id = :id"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$order) throw new Exception('Sales order not found.');
|
|
|
|
global $pdo1;
|
|
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
|
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
|
$guard->assertOpenDate($order['order_date'] ?: date('Y-m-d'), 'Order deletion');
|
|
}
|
|
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_invoice
|
|
WHERE company_id = :cid AND order_id = :id"
|
|
);
|
|
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth2->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — this order has linked invoices. Delete the invoices first.');
|
|
}
|
|
|
|
$sth3 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_return
|
|
WHERE company_id = :cid AND order_id = :id"
|
|
);
|
|
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$sth3->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — this order has linked returns. Delete the returns first.');
|
|
}
|
|
|
|
// Block if any approved stock-out rows exist; user must reverse via ICS first
|
|
$tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables();
|
|
|
|
foreach ($tables as $table) {
|
|
$chk = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM `{$table}`
|
|
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status = 1"
|
|
);
|
|
$chk->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
if ((int)$chk->fetchColumn() > 0) {
|
|
throw new Exception('Cannot delete — stock-out for this order has been approved. Reverse via ICS first.');
|
|
}
|
|
}
|
|
|
|
// Negate draft/pending stock-out rows (status != 1)
|
|
foreach ($tables as $table) {
|
|
$this->pdo->prepare(
|
|
"UPDATE `{$table}` SET company_id = company_id * -1
|
|
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status != 1"
|
|
)->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
}
|
|
|
|
// Release converted_qty on source quotation
|
|
$source = (string)($order['source'] ?? '');
|
|
$source_id = (int)($order['source_id'] ?? 0);
|
|
if ($source === 'quotation' && $source_id > 0) {
|
|
$sth5 = $this->pdo->prepare(
|
|
"SELECT item_id, quantity FROM td_order_item
|
|
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
|
|
);
|
|
$sth5->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
$items = $sth5->fetchAll(PDO::FETCH_ASSOC);
|
|
$dec = $this->pdo->prepare(
|
|
"UPDATE td_quotation_item
|
|
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
|
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
|
|
);
|
|
foreach ($items as $item) {
|
|
$dec->execute([
|
|
':qty' => (float)($item['quantity'] ?? 0),
|
|
':qid' => $source_id,
|
|
':item_id' => (int)($item['item_id'] ?? 0),
|
|
':cid' => $this->company_id,
|
|
]);
|
|
}
|
|
$this->pdo->prepare(
|
|
"UPDATE td_quotation SET status = 2
|
|
WHERE id = :id AND company_id = :cid AND status = 5"
|
|
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order_item SET company_id = company_id * -1
|
|
WHERE order_id = :id AND company_id = :cid"
|
|
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order SET company_id = company_id * -1
|
|
WHERE id = :id AND company_id = :cid"
|
|
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
/**
|
|
* Update shipping tracking number. Fulfillment itself is derived from
|
|
* linked stock-out rows and tracking, not selected manually.
|
|
*/
|
|
public function updateShippingTracking(
|
|
int $order_id,
|
|
string $tracking_number,
|
|
array $logging,
|
|
bool $auto_complete = true
|
|
): void {
|
|
// ── Load order ────────────────────────────────────────────────────
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT * FROM td_order
|
|
WHERE company_id = :company_id AND id = :id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
|
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$order) {
|
|
throw new Exception("Order not found.");
|
|
}
|
|
|
|
$status = (int)$order['status'];
|
|
$current_tracking = trim((string)($order['shipping_tracking_number'] ?? ''));
|
|
$tracking_number = trim($tracking_number);
|
|
|
|
if ($status === -1) {
|
|
throw new Exception("Cannot update a cancelled order.");
|
|
}
|
|
if ($status < 1) {
|
|
throw new Exception("Confirm the order before adding a tracking number.");
|
|
}
|
|
if ($tracking_number === $current_tracking) {
|
|
throw new Exception("No tracking changes to save.");
|
|
}
|
|
|
|
// ── Persist ───────────────────────────────────────────────────────
|
|
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
|
$log[] = array_merge($logging, [
|
|
'action' => 'update_tracking',
|
|
'shipping_tracking_number' => $tracking_number,
|
|
]);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_order SET
|
|
shipping_tracking_number = :shipping_tracking_number,
|
|
`log` = :log
|
|
WHERE id = :id AND company_id = :company_id"
|
|
)->execute([
|
|
':shipping_tracking_number' => $tracking_number,
|
|
':log' => json_encode($log),
|
|
':id' => $order_id,
|
|
':company_id' => $this->company_id,
|
|
]);
|
|
}
|
|
}
|