Orders: order, return, invoice
This commit is contained in:
@@ -705,4 +705,39 @@ function format_number(value, decimal) {
|
||||
var parts = n.toString().split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* set_btn_state(selector, enabled, hint)
|
||||
*
|
||||
* Enable or disable a button with consistent visual feedback:
|
||||
* enabled → restores original btn-* classes, removes disabled, clears title
|
||||
* disabled → swaps to btn-secondary, adds disabled attr, sets title hint
|
||||
*
|
||||
* Stores the original class on first call via data-original-class so
|
||||
* repeated calls always restore the correct variant.
|
||||
*
|
||||
* @param {string} selector jQuery selector e.g. '#btn_confirm'
|
||||
* @param {boolean} enabled true = enable, false = disable
|
||||
* @param {string} hint Tooltip text shown when disabled
|
||||
*/
|
||||
function set_btn_state(selector, enabled, hint) {
|
||||
var $btn = $(selector);
|
||||
if (!$btn.length) return;
|
||||
|
||||
// Store original classes once
|
||||
if (!$btn.data('original-class')) {
|
||||
$btn.data('original-class', $btn.attr('class'));
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
$btn.attr('class', $btn.data('original-class'))
|
||||
.prop('disabled', false)
|
||||
.attr('title', '');
|
||||
} else {
|
||||
$btn.attr('class', $btn.data('original-class')
|
||||
.replace(/btn-(outline-)?[a-z]+/g, '') + ' btn-light')
|
||||
.prop('disabled', true)
|
||||
.attr('title', hint || '');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
* 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
|
||||
*
|
||||
* Designed to accept new keys without schema changes.
|
||||
*/
|
||||
@@ -17,7 +20,8 @@ class CompanySettingManager
|
||||
|
||||
// ── Known keys with their defaults ───────────────────────────────────────
|
||||
private const DEFAULTS = [
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'auto_invoice_and_credit_note' => 0, // manual by default
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $companyId)
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* InvoiceManager
|
||||
*
|
||||
* Handles all read and write operations for td_invoice.
|
||||
* Supports three doc_type values: invoice | credit_note | debit_note
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getInvoiceList, getInvoiceById,
|
||||
* generateInvoiceNumber, saveInvoice,
|
||||
* createFromOrder, createCreditNote,
|
||||
* voidInvoice
|
||||
*
|
||||
* Key design decisions:
|
||||
* - invoice.items is a JSON snapshot of td_order.items at issue time.
|
||||
* credit_note.items contains only the items being corrected.
|
||||
* - grand_total is positive for invoice/debit_note, negative for credit_note.
|
||||
* - createFromOrder() is called by confirmOrder() when auto_invoice=1,
|
||||
* or manually from the order detail page. Always creates status=0 (draft).
|
||||
* - createCreditNote() is called by ReturnManager::approveReturn() —
|
||||
* never directly by the user.
|
||||
* - voidInvoice() only works on invoices with no approved credit notes
|
||||
* summing to grand_total (partial credit notes must be resolved first).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class InvoiceManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next sequential document number.
|
||||
*
|
||||
* @param string $doc_type 'invoice' | 'credit_note' | 'debit_note'
|
||||
* @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001"
|
||||
*/
|
||||
private function generateInvoiceNumber(string $doc_type): string
|
||||
{
|
||||
$prefix_map = [
|
||||
'invoice' => 'INV',
|
||||
'credit_note' => 'CN',
|
||||
'debit_note' => 'DN',
|
||||
];
|
||||
$prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT invoice_number FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND doc_type = :doc_type
|
||||
AND invoice_number LIKE :prefix
|
||||
ORDER BY invoice_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':doc_type' => $doc_type,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all invoices for the company ordered by id DESC.
|
||||
* Optionally filter by order_id or doc_type.
|
||||
*
|
||||
* @param int $order_id Filter by td_order.id (0 = all)
|
||||
* @param string $doc_type Filter by doc_type ('' = all)
|
||||
* @return array
|
||||
*/
|
||||
public function getInvoiceList(int $order_id = 0, string $doc_type = ''): array
|
||||
{
|
||||
$where = ['i.company_id = :company_id'];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($order_id > 0) {
|
||||
$where[] = 'i.order_id = :order_id';
|
||||
$params[':order_id'] = $order_id;
|
||||
}
|
||||
if ($doc_type !== '') {
|
||||
$where[] = 'i.doc_type = :doc_type';
|
||||
$params[':doc_type'] = $doc_type;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single invoice by id, with items decoded.
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @return array|false
|
||||
*/
|
||||
public function getInvoiceById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE i.company_id = :company_id
|
||||
AND i.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a draft invoice from a confirmed order.
|
||||
*
|
||||
* Snapshots td_order.items into td_invoice.items.
|
||||
* Copies totals from the order directly.
|
||||
* Always creates status=0 (draft) — user must manually issue.
|
||||
*
|
||||
* Called by:
|
||||
* - confirmOrder() engine when auto_invoice=1
|
||||
* - "Proceed to Invoice" button on order detail page (manual)
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_invoice.id
|
||||
* @throws Exception If order not found or invoice already exists for this order.
|
||||
*/
|
||||
public function createFromOrder(int $order_id, array $logging): int
|
||||
{
|
||||
// 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.");
|
||||
}
|
||||
if ((int)$order['status'] < 1) {
|
||||
throw new Exception("Invoice can only be created for confirmed orders.");
|
||||
}
|
||||
|
||||
// Block duplicate invoice for same order
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND order_id = :order_id
|
||||
AND doc_type = 'invoice'
|
||||
AND status != 4"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("An active invoice already exists for this order.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_from_order'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_invoice
|
||||
(company_id, uuid, doc_type, invoice_number, ref_invoice_id,
|
||||
order_id, contact_id, issued_date, due_date,
|
||||
subtotal, discount, tax, shipping_fee, grand_total,
|
||||
items, status, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, 'invoice', :invoice_number, 0,
|
||||
:order_id, :contact_id, :issued_date, NULL,
|
||||
:subtotal, :discount, :tax, :shipping_fee, :grand_total,
|
||||
:items, 0, '', :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('invoice'),
|
||||
':order_id' => $order_id,
|
||||
':contact_id' => (int)$order['contact_id'],
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':subtotal' => $order['subtotal'],
|
||||
':discount' => $order['discount'],
|
||||
':tax' => $order['tax'],
|
||||
':shipping_fee' => $order['shipping_fee'],
|
||||
':grand_total' => $order['grand_total'],
|
||||
':items' => $order['items'], // already JSON string
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata on an existing draft invoice.
|
||||
*
|
||||
* Only allowed while status = 0 (draft).
|
||||
* Editable fields: due_date, notes.
|
||||
* Totals are not editable — they snapshot from the order.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, due_date, notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception If invoice not found or not in draft status.
|
||||
*/
|
||||
public function saveInvoice(array $data, array $logging): void
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_invoice
|
||||
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("Invoice not found.");
|
||||
}
|
||||
if ((int)$row['status'] !== 0) {
|
||||
throw new Exception("Only draft invoices can be edited.");
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET
|
||||
due_date = :due_date,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':due_date' => $data['due_date'] ?: null,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a draft invoice (status 0 → 1).
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception If not found or not draft.
|
||||
*/
|
||||
public function issueInvoice(int $id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_invoice
|
||||
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("Invoice not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft invoices can be issued.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'issue']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET
|
||||
status = 1,
|
||||
issued_date = :issued_date,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a credit note linked to a parent invoice.
|
||||
*
|
||||
* Called automatically by ReturnManager::approveReturn().
|
||||
* grand_total is stored as negative value for net-balance queries.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $ref_invoice_id Parent td_invoice.id
|
||||
* @param array $items Items being credited (subset of invoice items)
|
||||
* @param float $amount Credit amount (positive — stored as negative internally)
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_invoice.id (credit note)
|
||||
* @throws Exception If parent invoice not found or not issued.
|
||||
*/
|
||||
public function createCreditNote(int $ref_invoice_id, array $items, float $amount, array $logging): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id AND doc_type = 'invoice'"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]);
|
||||
$parent = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$parent) {
|
||||
throw new Exception("Parent invoice not found.");
|
||||
}
|
||||
if ((int)$parent['status'] < 1) {
|
||||
throw new Exception("Cannot credit a draft invoice. Issue it first.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_invoice
|
||||
(company_id, uuid, doc_type, invoice_number, ref_invoice_id,
|
||||
order_id, contact_id, issued_date, due_date,
|
||||
subtotal, discount, tax, shipping_fee, grand_total,
|
||||
items, status, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, 'credit_note', :invoice_number, :ref_invoice_id,
|
||||
:order_id, :contact_id, :issued_date, NULL,
|
||||
:amount, 0, 0, 0, :grand_total,
|
||||
:items, 1, '', :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('credit_note'),
|
||||
':ref_invoice_id' => $ref_invoice_id,
|
||||
':order_id' => (int)$parent['order_id'],
|
||||
':contact_id' => (int)$parent['contact_id'],
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':amount' => $amount,
|
||||
':grand_total' => -abs($amount), // negative for net-balance queries
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an invoice (status → 4).
|
||||
*
|
||||
* Only allowed if no issued credit notes exist for this invoice,
|
||||
* or the sum of credit notes equals the full grand_total.
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function voidInvoice(int $id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_invoice
|
||||
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("Invoice not found.");
|
||||
if ((int)$row['status'] === 4) throw new Exception("Invoice is already void.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'void']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET status = 4, `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* OrderManager
|
||||
*
|
||||
* Handles all read and write operations for sales orders (td_order)
|
||||
* and their downstream stock-out effects on td_stock_<warehouse> tables.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
||||
* saveOrder, confirmOrder, cancelOrder
|
||||
*
|
||||
* 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 racks via FIFO — oldest approved stock-in row
|
||||
* still rack-occupied, ordered by td_stock_<wh>.date ASC.
|
||||
* - 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.
|
||||
* - 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.
|
||||
*
|
||||
* 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 table names (td_stock_<warehouse>) are sanitised with
|
||||
* preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation —
|
||||
* no user input ever reaches a table identifier directly.
|
||||
*/
|
||||
class OrderManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<warehouse> table name for a warehouse_id.
|
||||
*
|
||||
* Does not filter by status — allows reading inactive warehouses
|
||||
* for historical order lookups.
|
||||
*
|
||||
* @param int $warehouse_id
|
||||
* @return string Sanitised table name e.g. "td_stock_Main".
|
||||
* @throws Exception If warehouse not found.
|
||||
*/
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
}
|
||||
|
||||
/**
|
||||
* FIFO rack pick — find the oldest approved stock-in row for a SKU
|
||||
* in a given warehouse that is still rack-occupied.
|
||||
*
|
||||
* "Oldest" = smallest date value among status=1 stock-in rows
|
||||
* that have an md_rack 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/rack from md_rack,
|
||||
* or null if no available stock in this warehouse.
|
||||
*/
|
||||
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
|
||||
{
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack
|
||||
FROM `{$table}` s
|
||||
INNER JOIN md_rack 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
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.status = 1
|
||||
ORDER BY s.date 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(): string
|
||||
{
|
||||
$prefix = 'ORD-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT order_number FROM td_order
|
||||
WHERE company_id = :company_id
|
||||
AND order_number LIKE :prefix
|
||||
ORDER BY order_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
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.created_at DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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, 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 = $data['items'] ?? [];
|
||||
|
||||
// Calculate totals from items
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$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, `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.");
|
||||
}
|
||||
if ((int)$row['status'] !== 0) {
|
||||
throw new Exception("Only draft orders can be edited.");
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
contact_id = :contact_id,
|
||||
order_date = :order_date,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"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)
|
||||
VALUES
|
||||
(:company_id, :uuid, :order_number, :contact_id, :order_date,
|
||||
0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':order_number' => $this->generateOrderNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a draft order — create stock-out rows (status=0) for each item
|
||||
* using FIFO rack selection, then advance the order to status=1.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
|
||||
* 2. INSERT into td_stock_<wh> 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).
|
||||
*
|
||||
* Rack 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 rack 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.");
|
||||
}
|
||||
|
||||
$items = json_decode($order['items'] ?? '[]', true) ?: [];
|
||||
|
||||
if (empty($items)) {
|
||||
throw new Exception("Cannot confirm an order with no items.");
|
||||
}
|
||||
|
||||
// ── Per-item: FIFO pick + insert stock-out row ────────────────────
|
||||
foreach ($items as $i => &$item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception(
|
||||
"Item #{$i}: missing warehouse_id or product_sku."
|
||||
);
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
|
||||
if (!$rack_stock) {
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"No available stock for \"{$name}\" in the selected warehouse."
|
||||
);
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($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, rack,
|
||||
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, :rack,
|
||||
: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' => (float)$item['quantity'],
|
||||
':zone' => $rack_stock['zone'],
|
||||
':aisle' => $rack_stock['aisle'],
|
||||
':rack' => $rack_stock['rack'],
|
||||
':contact_id' => (int)$order['contact_id'],
|
||||
':description' => $order['order_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':ref_id' => (int)$rack_stock['id'],
|
||||
':lot_number' => $rack_stock['lot_number'] ?? '',
|
||||
':serial_number' => $rack_stock['serial_number'] ?? '',
|
||||
':source_id' => $order_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
]);
|
||||
|
||||
// Write rack context + stock_out_id back into the item object
|
||||
$item['zone'] = $rack_stock['zone'];
|
||||
$item['aisle'] = $rack_stock['aisle'];
|
||||
$item['rack'] = $rack_stock['rack'];
|
||||
$item['lot_number'] = $rack_stock['lot_number'] ?? '';
|
||||
$item['serial_number'] = $rack_stock['serial_number'] ?? '';
|
||||
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Auto-approve: immediately release rack + 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: items (with stock_out_id) + status=1 ───────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
status = 1,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order and flip all linked draft stock-out rows to 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.
|
||||
*
|
||||
* Cancel logic is intentionally self-contained — status=-1 rows have no
|
||||
* rack/balance side effects so WarehouseManager is not involved.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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.");
|
||||
}
|
||||
if ($status >= 2) {
|
||||
throw new Exception(
|
||||
"Cannot cancel an order that is processing or completed. " .
|
||||
"Please reverse stock movements manually first."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Guard: no approved stock-out rows ─────────────────────────────
|
||||
// Discover all td_stock_* tables and check for approved rows
|
||||
// linked to this order before making any changes.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$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) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
AND status = 0"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ReturnManager
|
||||
*
|
||||
* Handles all read and write operations for td_return.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
|
||||
* saveReturn, confirmReturn
|
||||
*
|
||||
* Key design decisions:
|
||||
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
|
||||
* - confirmReturn() is the single restock path for all customer returns:
|
||||
* 1. INSERT td_stock_<wh> in rows (source='return', source_id=return_id,
|
||||
* status=1 ALWAYS — confirming a return is a final business decision,
|
||||
* no separate warehouse approval step needed).
|
||||
* 2. occupyRack() + adjustBalance() via WarehouseManager.
|
||||
* 3. If auto_invoice_and_credit_note=1 → InvoiceManager::createCreditNote()
|
||||
* auto-generates a CN linked to the original invoice (status=1 issued).
|
||||
* If =0 → user creates CN manually from the return detail page.
|
||||
* - Return items stored as JSON in td_return.items (same pattern as td_order).
|
||||
* - Each item references stock_out_id from the original order so the correct
|
||||
* warehouse/rack/lot context is known for restock.
|
||||
*
|
||||
* 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 table names are sanitised before interpolation.
|
||||
*/
|
||||
class ReturnManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
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 generateReturnNumber(): string
|
||||
{
|
||||
$prefix = 'RET-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT return_number FROM td_return
|
||||
WHERE company_id = :company_id
|
||||
AND return_number LIKE :prefix
|
||||
ORDER BY return_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all return requests for the company ordered by id DESC.
|
||||
*
|
||||
* @param int $order_id Filter by td_order.id (0 = all)
|
||||
* @return array
|
||||
*/
|
||||
public function getReturnList(int $order_id = 0): array
|
||||
{
|
||||
$where = ['r.company_id = :company_id'];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($order_id > 0) {
|
||||
$where[] = 'r.order_id = :order_id';
|
||||
$params[':order_id'] = $order_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = r.company_id
|
||||
AND o.id = r.order_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single return by id, with items decoded.
|
||||
*
|
||||
* @param int $id td_return.id
|
||||
* @return array|false
|
||||
*/
|
||||
public function getReturnById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = r.company_id
|
||||
AND o.id = r.order_id
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new return request (status=0 draft) or update an existing draft.
|
||||
*
|
||||
* items JSON array — each element mirrors td_order.items with stock_out_id
|
||||
* referencing the original stock-out row so confirmReturn() knows which
|
||||
* rack/warehouse/lot to restock.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, order_id, invoice_id, contact_id,
|
||||
* return_date, reason, items (array), notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_return.id on insert, 0 on update.
|
||||
* @throws Exception If updating a non-draft return.
|
||||
*/
|
||||
public function saveReturn(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$refund_amount = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_return
|
||||
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("Return not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be edited.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET
|
||||
contact_id = :contact_id,
|
||||
return_date = :return_date,
|
||||
reason = :reason,
|
||||
items = :items,
|
||||
refund_amount = :refund_amount,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':refund_amount' => $refund_amount,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_return
|
||||
(company_id, uuid, return_number, order_id, invoice_id,
|
||||
contact_id, return_date, status,
|
||||
reason, refund_amount, items, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, :return_number, :order_id, :invoice_id,
|
||||
:contact_id, :return_date, 0,
|
||||
:reason, :refund_amount, :items, :notes, :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':order_id' => (int)($data['order_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode([$logging]),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a return — restock items back to warehouse + optionally create credit note.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. INSERT td_stock_<wh> in row with type='in', status=1 (force confirmed),
|
||||
* source='return', source_id=return_id.
|
||||
* 2. occupyRack() — place returned stock back into the original rack.
|
||||
* 3. adjustBalance() — update warehouse_balance.
|
||||
*
|
||||
* After all items:
|
||||
* 4. UPDATE td_return.status = 1 (confirmed).
|
||||
* 5. If auto_invoice_and_credit_note=true AND invoice_id > 0:
|
||||
* InvoiceManager::createCreditNote() — auto CN, status=1 (issued).
|
||||
* If false: CN must be created manually from the return detail page.
|
||||
*
|
||||
* Stock-in rows are ALWAYS status=1 regardless of default_stock_status config.
|
||||
* Confirming a return is a final business decision — no warehouse re-approval needed.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $return_id td_return.id to confirm.
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param object $whMgmt WarehouseManager instance.
|
||||
* @param object $invMgmt InvoiceManager instance.
|
||||
* @param bool $auto_cn Whether to auto-create credit note.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirmReturn(
|
||||
int $return_id,
|
||||
string $uuid,
|
||||
array $logging,
|
||||
$whMgmt,
|
||||
$invMgmt,
|
||||
bool $auto_cn = false
|
||||
): void {
|
||||
|
||||
$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 request not found.");
|
||||
if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled.");
|
||||
|
||||
$items = json_decode($return['items'] ?? '[]', true) ?: [];
|
||||
if (empty($items)) throw new Exception("Cannot confirm a return with no items.");
|
||||
|
||||
// ── Per-item: restock ─────────────────────────────────────────────
|
||||
foreach ($items as $i => $item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception("Item #{$i}: missing warehouse_id or product_sku.");
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$item_uuid = $uuid . '_ret_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
// INSERT stock-in row — status=1 forced
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
lot_number, serial_number,
|
||||
source, source_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'in',
|
||||
:lot_number, :serial_number,
|
||||
'return', :source_id, :price, 1)"
|
||||
)->execute([
|
||||
':uuid' => $item_uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => $quantity,
|
||||
':zone' => $item['zone'] ?? '',
|
||||
':aisle' => $item['aisle'] ?? '',
|
||||
':rack' => $item['rack'] ?? '',
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':lot_number' => $item['lot_number'] ?? '',
|
||||
':serial_number' => $item['serial_number'] ?? '',
|
||||
':source_id' => $return_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
]);
|
||||
|
||||
$stock_in_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Occupy rack + adjust balance — forced since status=1
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$item['zone'] ?? '',
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
|
||||
// ── Update return status → 1 (confirmed) ─────────────────────────
|
||||
$log = json_decode($return['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
// ── Auto credit note ──────────────────────────────────────────────
|
||||
$invoice_id = (int)$return['invoice_id'];
|
||||
|
||||
if ($auto_cn && $invoice_id > 0) {
|
||||
$invMgmt->createCreditNote(
|
||||
$invoice_id,
|
||||
$items,
|
||||
(float)$return['refund_amount'],
|
||||
array_merge($logging, ['action' => 'auto_credit_note'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user