warehouse layers [switchable]
This commit is contained in:
@@ -5,29 +5,38 @@
|
||||
* Table: company_setting (company_id, setting_key, value, log, updated_at)
|
||||
*
|
||||
* Current keys:
|
||||
* default_stock_status int 0 = draft (manual approve required)
|
||||
* 1 = auto-approve on save
|
||||
* auto_invoice_and_credit_note int 0 = manual
|
||||
* 1 = auto-generate invoice on confirm order
|
||||
* + auto-generate credit note on confirm return
|
||||
* default_stock_status int 0 = draft, 1 = auto-approve on save
|
||||
* auto_invoice_and_credit_note int 0 = manual, 1 = auto-generate
|
||||
* auto_complete_on_ship int 0 = manual, 1 = auto-complete on shipped
|
||||
* advanced_location int 0 = simple (single location), 1 = zone/aisle/rack
|
||||
* location_label_rack string label for rack / single location field
|
||||
* location_label_zone string label for zone (advanced only)
|
||||
* location_label_aisle string label for aisle (advanced only)
|
||||
*
|
||||
* Designed to accept new keys without schema changes.
|
||||
*/
|
||||
class CompanySettingManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private ?PDO $transactionPdo;
|
||||
private int $companyId;
|
||||
|
||||
// ── Known keys with their defaults ───────────────────────────────────────
|
||||
private const DEFAULTS = [
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'auto_invoice_and_credit_note' => 0, // manual by default
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'auto_invoice_and_credit_note' => 0, // manual by default
|
||||
'auto_complete_on_ship' => 1, // auto-complete by default
|
||||
'advanced_location' => 0, // simple mode by default
|
||||
'location_label_rack' => 'Location', // single label default
|
||||
'location_label_zone' => 'Zone', // advanced level 1 default
|
||||
'location_label_aisle' => 'Aisle', // advanced level 2 default
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $companyId)
|
||||
public function __construct(PDO $pdo, int $companyId, ?PDO $transactionPdo = null)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $companyId;
|
||||
$this->pdo = $pdo;
|
||||
$this->transactionPdo = $transactionPdo;
|
||||
$this->companyId = $companyId;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -80,6 +89,32 @@ class CompanySettingManager
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return settings that are locked by existing transaction data.
|
||||
*
|
||||
* @return array Locked key => reason pairs.
|
||||
*/
|
||||
public function getTransactionLocks(): array
|
||||
{
|
||||
$locked = [];
|
||||
|
||||
if (!$this->transactionPdo || $this->isLocalhostRequest()) {
|
||||
return $locked;
|
||||
}
|
||||
|
||||
if ($this->hasStockTransactions()) {
|
||||
$locked['default_stock_status'] = 'stock transactions already exist';
|
||||
$locked['advanced_location'] = 'stock transactions already exist';
|
||||
}
|
||||
|
||||
if ($this->hasOrderTransactions()) {
|
||||
$locked['auto_complete_on_ship'] = 'order, invoice, or return transactions already exist';
|
||||
$locked['auto_invoice_and_credit_note'] = 'order, invoice, or return transactions already exist';
|
||||
}
|
||||
|
||||
return $locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single setting value.
|
||||
*
|
||||
@@ -114,6 +149,144 @@ class CompanySettingManager
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return keys whose value cannot be changed because related transactions exist.
|
||||
*
|
||||
* @param array $incoming Requested key => value pairs.
|
||||
* @return array Blocked key => reason pairs.
|
||||
*/
|
||||
private function blockedChanges(array $incoming): array
|
||||
{
|
||||
if (!$this->transactionPdo || $this->isLocalhostRequest()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$current = $this->getAll();
|
||||
$blocked = [];
|
||||
|
||||
$stock_keys = [
|
||||
'default_stock_status',
|
||||
'advanced_location',
|
||||
];
|
||||
|
||||
$order_keys = [
|
||||
'auto_complete_on_ship',
|
||||
'auto_invoice_and_credit_note',
|
||||
];
|
||||
|
||||
$needs_stock_check = false;
|
||||
foreach ($stock_keys as $key) {
|
||||
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
|
||||
$needs_stock_check = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$needs_order_check = false;
|
||||
foreach ($order_keys as $key) {
|
||||
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
|
||||
$needs_order_check = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$has_stock_transactions = $needs_stock_check ? $this->hasStockTransactions() : false;
|
||||
$has_order_transactions = $needs_order_check ? $this->hasOrderTransactions() : false;
|
||||
|
||||
if ($has_stock_transactions) {
|
||||
foreach ($stock_keys as $key) {
|
||||
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
|
||||
$blocked[$key] = 'stock transactions already exist';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($has_order_transactions) {
|
||||
foreach ($order_keys as $key) {
|
||||
if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) {
|
||||
$blocked[$key] = 'order, invoice, or return transactions already exist';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
private function hasOrderTransactions(): bool
|
||||
{
|
||||
foreach (['td_order', 'td_invoice', 'td_return'] as $table) {
|
||||
if ($this->tableHasCompanyRows($table)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasStockTransactions(): bool
|
||||
{
|
||||
if ($this->tableHasCompanyRows('warehouse_balance')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->tableHasCompanyRows('md_lot')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$sth = $this->transactionPdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($warehouses as $warehouse_name) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name);
|
||||
if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function tableHasCompanyRows(string $table): bool
|
||||
{
|
||||
if (!$this->tableExists($table)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$safe_table = str_replace('`', '``', $table);
|
||||
$sth = $this->transactionPdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$safe_table}` WHERE company_id = :company_id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private function tableExists(string $table): bool
|
||||
{
|
||||
$sth = $this->transactionPdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table_name"
|
||||
);
|
||||
$sth->execute([':table_name' => $table]);
|
||||
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private function isLocalhostRequest(): bool
|
||||
{
|
||||
$host = strtolower($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '');
|
||||
$host = preg_replace('/:\d+$/', '', $host);
|
||||
|
||||
return in_array($host, ['localhost', '127.0.0.1', '::1'], true)
|
||||
|| ($_SERVER['REMOTE_ADDR'] ?? '') === '127.0.0.1'
|
||||
|| ($_SERVER['REMOTE_ADDR'] ?? '') === '::1';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP handler — read or update settings via AJAX.
|
||||
*
|
||||
@@ -127,14 +300,48 @@ class CompanySettingManager
|
||||
$action = $data['action'] ?? '';
|
||||
|
||||
if ($action === 'read') {
|
||||
echo json_encode(['success' => 1, 'output' => $this->getAll()]);
|
||||
echo json_encode([
|
||||
'success' => 1,
|
||||
'output' => $this->getAll(),
|
||||
'locked' => $this->getTransactionLocks(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
$allowed = array_keys(self::DEFAULTS);
|
||||
$incoming = [];
|
||||
foreach ($allowed as $key) {
|
||||
$this->set($key, $data[$key] ?? self::DEFAULTS[$key]);
|
||||
$incoming[$key] = $data[$key] ?? self::DEFAULTS[$key];
|
||||
}
|
||||
|
||||
$blocked = $this->blockedChanges($incoming);
|
||||
if (!empty($blocked)) {
|
||||
$labels = [
|
||||
'default_stock_status' => 'Default Stock Status',
|
||||
'advanced_location' => 'Advanced Location',
|
||||
'auto_invoice_and_credit_note' => 'Invoice & Credit Note Generation',
|
||||
'auto_complete_on_ship' => 'Auto-Complete on Shipped',
|
||||
];
|
||||
|
||||
$names = array_map(
|
||||
fn($key) => $labels[$key] ?? $key,
|
||||
array_keys($blocked)
|
||||
);
|
||||
|
||||
http_response_code(409);
|
||||
echo json_encode([
|
||||
'success' => 0,
|
||||
'message' => 'Cannot change ' . implode(', ', $names) . ' because relevant transactions already exist.',
|
||||
'blocked' => $blocked,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($incoming as $key => $value) {
|
||||
if ((string)$this->get($key) !== (string)$value) {
|
||||
$this->set($key, $value);
|
||||
}
|
||||
}
|
||||
echo json_encode(['success' => 1, 'message' => 'Settings saved.']);
|
||||
exit;
|
||||
@@ -144,4 +351,4 @@ class CompanySettingManager
|
||||
echo json_encode(['success' => 0, 'message' => 'Invalid action.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
||||
* saveOrder, confirmOrder, cancelOrder
|
||||
* saveOrder, confirmOrder, cancelOrder,
|
||||
* updateFulfillmentStatus
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Order items are stored as a JSON array in td_order.items.
|
||||
@@ -19,10 +20,10 @@
|
||||
* - confirmOrder() creates stock-out rows with status=0 (draft).
|
||||
* Warehouse staff approve them via the existing approve_stock.php
|
||||
* engine, which handles rack release and balance adjustment.
|
||||
* - cancelOrder() sets td_order.status = -1 and flips all linked
|
||||
* draft stock-out rows (status=0) to status=-1 (cancelled).
|
||||
* Approved stock-out rows block cancellation — caller must handle
|
||||
* these manually before cancelling.
|
||||
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
|
||||
* stock-out rows (status → -1) across all td_stock_* tables.
|
||||
* Cancellation is blocked if any active invoice (status != -1) or
|
||||
* active return (status != -1) is linked to the order.
|
||||
* - Cancel logic is intentionally self-contained here. status=-1 is
|
||||
* an order-domain concept with no rack/balance side effects, so
|
||||
* WarehouseManager and StockManager are not involved.
|
||||
@@ -240,7 +241,7 @@ class OrderManager {
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, order_date, items (array),
|
||||
* discount, tax, shipping_fee, notes.
|
||||
* 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.
|
||||
@@ -257,6 +258,7 @@ class OrderManager {
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
@@ -288,6 +290,7 @@ class OrderManager {
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
shipping_tracking_number = :shipping_tracking_number,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
@@ -300,6 +303,7 @@ class OrderManager {
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':shipping_tracking_number' => $tracking_no,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
@@ -317,11 +321,11 @@ class OrderManager {
|
||||
"INSERT INTO td_order
|
||||
(company_id, uuid, order_number, contact_id, order_date,
|
||||
status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, grand_total, items, notes, `log`, created_at)
|
||||
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :order_number, :contact_id, :order_date,
|
||||
0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
@@ -332,6 +336,7 @@ class OrderManager {
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':shipping_tracking_number' => $tracking_no,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
@@ -484,28 +489,27 @@ class OrderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order and flip all linked draft stock-out rows to status=-1.
|
||||
* Cancel an order and soft-delete all linked stock-out rows (status → -1).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load the order — must be status=0 (draft) or status=1 (confirmed).
|
||||
* Orders at status=2 (processing) or status=3 (completed) cannot be
|
||||
* cancelled here; they require manual stock reversal first.
|
||||
* 2. Check that no linked stock-out rows are already approved (status=1).
|
||||
* If any are approved, throw — those must be deleted via the existing
|
||||
* deleteStockOut() flow before cancellation can proceed.
|
||||
* 3. UPDATE all td_stock_<wh> rows where
|
||||
* source='order' AND source_id=order_id AND status=0 → status=-1.
|
||||
* 4. UPDATE td_order.status = -1.
|
||||
* Business rule:
|
||||
* An order can be cancelled (Draft or Confirmed) as long as no active
|
||||
* downstream documents exist. Active means status != -1 (not voided /
|
||||
* not cancelled).
|
||||
*
|
||||
* Cancel logic is intentionally self-contained — status=-1 rows have no
|
||||
* rack/balance side effects so WarehouseManager is not involved.
|
||||
* Downstream documents that block cancellation:
|
||||
* - td_invoice where order_id = $order_id AND status != -1
|
||||
* - td_return where order_id = $order_id AND status != -1
|
||||
*
|
||||
* Stock-out rows are children of the order — they follow the parent
|
||||
* and are never independently approved. On cancel, 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 If order not found, already cancelled, in a non-cancellable
|
||||
* status, or has approved stock-out rows.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function cancelOrder(int $order_id, array $logging): void
|
||||
{
|
||||
@@ -528,14 +532,41 @@ class OrderManager {
|
||||
}
|
||||
if ($status >= 2) {
|
||||
throw new Exception(
|
||||
"Cannot cancel an order that is processing or completed. " .
|
||||
"Please reverse stock movements manually first."
|
||||
"Cannot cancel an order that is processing or completed."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Guard: no approved stock-out rows ─────────────────────────────
|
||||
// Discover all td_stock_* tables and check for approved rows
|
||||
// linked to this order before making any changes.
|
||||
// ── 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 != -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 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."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Soft-delete all linked stock-out rows ─────────────────────────
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
@@ -545,33 +576,13 @@ class OrderManager {
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_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 — some stock-out rows for this order are already " .
|
||||
"approved. Please delete them from the Stock Out page first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flip draft stock-out rows to cancelled ────────────────────────
|
||||
foreach ($tables as $table) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
"UPDATE `{$safe}` SET status = -1
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
AND status = 0"
|
||||
AND status != -1"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_id,
|
||||
@@ -593,4 +604,109 @@ class OrderManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fulfillment sub-status of a confirmed order.
|
||||
*
|
||||
* Fulfillment status values:
|
||||
* 1 = Picking — warehouse staff are picking items
|
||||
* 2 = Packed — items are packed, ready to ship
|
||||
* 3 = Shipped — goods have left the warehouse
|
||||
*
|
||||
* Rules:
|
||||
* - Only allowed on orders with status >= 1 (confirmed) and not cancelled.
|
||||
* - Fulfillment must move forward only (no going backwards).
|
||||
* - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped),
|
||||
* order status is automatically advanced to 3 (Completed).
|
||||
* - If auto_complete_on_ship = false, order status is set to 2 (Processing)
|
||||
* on first fulfillment update, and stays there until manually completed.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id
|
||||
* @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped
|
||||
* @param string $tracking_number Shipping carrier tracking number.
|
||||
* @param array $logging Audit entry.
|
||||
* @param bool $auto_complete Whether Shipped auto-drives order to Completed.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateFulfillmentStatus(
|
||||
int $order_id,
|
||||
int $fulfillment_status,
|
||||
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_fulfillment = (int)($order['fulfillment_status'] ?? 0);
|
||||
$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 updating fulfillment.");
|
||||
}
|
||||
if ($status === 3) {
|
||||
throw new Exception("Order is already completed.");
|
||||
}
|
||||
if (!in_array($fulfillment_status, [1, 2, 3], true)) {
|
||||
throw new Exception("Invalid fulfillment status.");
|
||||
}
|
||||
if ($fulfillment_status < $current_fulfillment) {
|
||||
throw new Exception("Fulfillment status can only move forward.");
|
||||
}
|
||||
if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) {
|
||||
throw new Exception("No fulfillment or tracking changes to save.");
|
||||
}
|
||||
|
||||
// ── Determine new order status ────────────────────────────────────
|
||||
$new_order_status = $status;
|
||||
|
||||
if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) {
|
||||
// Shipped + auto-complete → Completed
|
||||
$new_order_status = 3;
|
||||
} elseif ($fulfillment_status > $current_fulfillment && $status === 1) {
|
||||
// First fulfillment update → Processing
|
||||
$new_order_status = 2;
|
||||
}
|
||||
|
||||
// ── Persist ───────────────────────────────────────────────────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, [
|
||||
'action' => 'update_fulfillment',
|
||||
'fulfillment_status' => $fulfillment_status,
|
||||
'shipping_tracking_number' => $tracking_number,
|
||||
'order_status' => $new_order_status,
|
||||
]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
fulfillment_status = :fulfillment_status,
|
||||
shipping_tracking_number = :shipping_tracking_number,
|
||||
status = :status,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':fulfillment_status' => $fulfillment_status,
|
||||
':shipping_tracking_number' => $tracking_number,
|
||||
':status' => $new_order_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
|
||||
* saveReturn, confirmReturn
|
||||
* saveReturn, confirmReturn, cancelReturn
|
||||
*
|
||||
* Key design decisions:
|
||||
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
|
||||
@@ -386,4 +386,144 @@ class ReturnManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a return (Draft or Confirmed) and soft-delete linked stock-in rows.
|
||||
*
|
||||
* Business rule:
|
||||
* A return can be cancelled as long as no active downstream document exists.
|
||||
* Active means status != -1 (not voided).
|
||||
*
|
||||
* Downstream document that blocks cancellation:
|
||||
* - td_invoice (credit note) where ref_invoice_id links back via
|
||||
* order_id = return.order_id AND doc_type = 'credit_note' AND status != -1.
|
||||
* Credit notes store order_id (not return_id), so we match via order_id
|
||||
* and doc_type.
|
||||
*
|
||||
* If the return was confirmed, stock-in rows (source='return', source_id=$return_id)
|
||||
* were created with status=1 and rack/balance were already adjusted.
|
||||
* On cancel we soft-delete those rows (status → -1) and reverse the
|
||||
* rack occupation and balance via WarehouseManager.
|
||||
*
|
||||
* If the return was still draft, no stock rows exist — simple status flip.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $return_id td_return.id to cancel.
|
||||
* @param array $logging Audit entry appended to td_return.log.
|
||||
* @param object $whMgmt WarehouseManager instance (needed for confirmed reversal).
|
||||
* @throws Exception
|
||||
*/
|
||||
public function cancelReturn(int $return_id, array $logging, $whMgmt): void
|
||||
{
|
||||
// ── Load return ───────────────────────────────────────────────────
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$return) {
|
||||
throw new Exception("Return not found.");
|
||||
}
|
||||
|
||||
$status = (int)$return['status'];
|
||||
|
||||
if ($status === -1) {
|
||||
throw new Exception("Return is already cancelled.");
|
||||
}
|
||||
|
||||
// ── Guard: no active credit note ──────────────────────────────────
|
||||
$order_id = (int)$return['order_id'];
|
||||
|
||||
if ($order_id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND order_id = :order_id
|
||||
AND doc_type = 'credit_note'
|
||||
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 return has an active credit note. " .
|
||||
"Please void the credit note first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── If confirmed: reverse stock-in rows + rack/balance ────────────
|
||||
if ($status === 1) {
|
||||
$items = json_decode($return['items'] ?? '[]', true) ?: [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
if (!$warehouse_id || !$product_sku) continue;
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
|
||||
// Find the stock-in row created by confirmReturn()
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, rack FROM `{$safe}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
AND product_sku = :sku
|
||||
AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':return_id' => $return_id,
|
||||
':sku' => $product_sku,
|
||||
]);
|
||||
$stock_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$stock_row) continue;
|
||||
|
||||
// Soft-delete the stock-in row
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$safe}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':id' => $stock_row['id'],
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Release rack occupation
|
||||
$whMgmt->releaseRack(
|
||||
$warehouse_id,
|
||||
$stock_row['zone'],
|
||||
$stock_row['aisle'],
|
||||
$stock_row['rack'],
|
||||
$product_sku,
|
||||
$stock_row['id']
|
||||
);
|
||||
|
||||
// Reverse balance
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancel the return ─────────────────────────────────────────────
|
||||
$log = json_decode($return['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'cancel']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET
|
||||
status = -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $return_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,12 @@ class WarehouseManager {
|
||||
$from = trim($from);
|
||||
$to = trim($to);
|
||||
|
||||
// Numeric range
|
||||
// Same value — single location, any format allowed (e.g. "Shelf-A", "Bin-3")
|
||||
if ($from === $to) {
|
||||
return [$from];
|
||||
}
|
||||
|
||||
// Numeric range e.g. 1 → 10
|
||||
if (is_numeric($from) && is_numeric($to)) {
|
||||
$f = (int) $from;
|
||||
$t = (int) $to;
|
||||
@@ -86,15 +91,62 @@ class WarehouseManager {
|
||||
return array_map('strval', range($f, $t));
|
||||
}
|
||||
|
||||
// Single-letter alpha range
|
||||
// Single-letter alpha range e.g. A → Z
|
||||
if (ctype_alpha($from) && ctype_alpha($to)
|
||||
&& strlen($from) === 1 && strlen($to) === 1) {
|
||||
if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
return range($from, $to); // PHP range() handles 'A' → 'Z' natively
|
||||
if (ord(strtoupper($from)) > ord(strtoupper($to))) {
|
||||
throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
}
|
||||
return range(strtoupper($from), strtoupper($to));
|
||||
}
|
||||
|
||||
// Prefix + suffix range e.g. Shelf-A → Shelf-E or Bin-1 → Bin-5
|
||||
// Split on the last sequence of digits or single letter at the end
|
||||
$pattern = '/^(.*?)([A-Za-z]|[0-9]+)$/';
|
||||
if (preg_match($pattern, $from, $mf) && preg_match($pattern, $to, $mt)) {
|
||||
$prefix_f = $mf[1];
|
||||
$prefix_t = $mt[1];
|
||||
$suffix_f = $mf[2];
|
||||
$suffix_t = $mt[2];
|
||||
|
||||
// Prefixes must match
|
||||
if ($prefix_f !== $prefix_t) {
|
||||
throw new Exception(
|
||||
"Location prefix mismatch: '$prefix_f' vs '$prefix_t'. " .
|
||||
"From and To must share the same prefix (e.g. Shelf-A → Shelf-E)."
|
||||
);
|
||||
}
|
||||
|
||||
$prefix = $prefix_f;
|
||||
|
||||
// Suffix: numeric range
|
||||
if (is_numeric($suffix_f) && is_numeric($suffix_t)) {
|
||||
$f = (int) $suffix_f;
|
||||
$t = (int) $suffix_t;
|
||||
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
return array_map(fn($n) => $prefix . $n, range($f, $t));
|
||||
}
|
||||
|
||||
// Suffix: single-letter range
|
||||
if (ctype_alpha($suffix_f) && ctype_alpha($suffix_t)
|
||||
&& strlen($suffix_f) === 1 && strlen($suffix_t) === 1) {
|
||||
$sf = strtoupper($suffix_f);
|
||||
$st = strtoupper($suffix_t);
|
||||
if (ord($sf) > ord($st)) {
|
||||
throw new Exception("Range start ($from) must be <= end ($to)");
|
||||
}
|
||||
return array_map(fn($c) => $prefix . $c, range($sf, $st));
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
"Location suffix must be a number (e.g. 1–99) or single letter (e.g. A–Z). " .
|
||||
"Got: '$suffix_f' → '$suffix_t'"
|
||||
);
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
"Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'"
|
||||
"Invalid location range: '$from' → '$to'. " .
|
||||
"Use formats like: 1–10, A–Z, Shelf-1–Shelf-10, or Shelf-A–Shelf-Z."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -848,6 +900,16 @@ class WarehouseManager {
|
||||
*/
|
||||
public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array
|
||||
{
|
||||
if ($zone === '' || $aisle === '') {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||||
@@ -1703,6 +1765,21 @@ class WarehouseManager {
|
||||
*/
|
||||
public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array
|
||||
{
|
||||
if ($zone === '' || $aisle === '') {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(rack AS UNSIGNED), rack"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT rack FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
@@ -1746,17 +1823,20 @@ class WarehouseManager {
|
||||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||||
);
|
||||
$params[':zone'] = $zone;
|
||||
$params[':aisle'] = $aisle;
|
||||
$location_cond = '';
|
||||
if ($zone !== '' && $aisle !== '') {
|
||||
$location_cond = "AND r.zone = :zone AND r.aisle = :aisle";
|
||||
$params[':zone'] = $zone;
|
||||
$params[':aisle'] = $aisle;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT r.rack
|
||||
FROM md_rack r
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.warehouse = :warehouse
|
||||
AND r.zone = :zone
|
||||
AND r.aisle = :aisle
|
||||
AND r.product_sku = :product_sku
|
||||
{$location_cond}
|
||||
AND r.td_stock_id IN (
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
@@ -2055,4 +2135,4 @@ class WarehouseManager {
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user