From 3668f22e55de1e64ac3bd82de8f0ddedf5e607ba Mon Sep 17 00:00:00 2001 From: Thanakorn Date: Sat, 19 Sep 2026 07:32:07 +0700 Subject: [PATCH] Fix customer return confirm, auto credit note VAT and quotation link Store the put-away location on return lines (new td_return_item columns, needs setup.php), split auto credit notes into net and VAT with the parent's department, drop the write to a td_quotation column that does not exist, and have the demo seed confirm its customer return. --- app/assets/utils/classes/InvoiceManager.php | 38 +++++++++++++-- app/assets/utils/classes/OrderManager.php | 15 +++++- app/assets/utils/classes/ReturnManager.php | 29 ++++++++++- demo_seed_transactions.php | 54 ++++++++++++++------- setup.php | 12 +++++ 5 files changed, 121 insertions(+), 27 deletions(-) diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php index 6de171e..c41b1b0 100644 --- a/app/assets/utils/classes/InvoiceManager.php +++ b/app/assets/utils/classes/InvoiceManager.php @@ -905,18 +905,46 @@ class InvoiceManager { $log = [array_merge($logging, ['action' => 'create_credit_note'])]; + // Split the credited amount into net and VAT from the lines being + // credited. This used to store the whole VAT-inclusive amount as the + // subtotal with tax = 0, so the header disagreed with its own lines: the + // VAT report missed the output-tax reversal, and a GL formula posting + // from the header reversed revenue by the gross figure. + // + // The VAT is taken as "amount minus net" so the grand total still + // equals the caller's amount exactly, including any rounding adjustment + // the return carried; that adjustment is recorded as tax_adjustment. + // With no priced lines to split by, the amount is kept whole as before. + $net = round(array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0), 4); + $line_tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2); + + if ($net > 0 && $net <= abs($amount) + 0.005) { + $subtotal = $net; + $tax = round(abs($amount) - $net, 4); + $tax_adj = round($tax - $line_tax, 2); + } else { + $subtotal = abs($amount); + $tax = 0.0; + $tax_adj = 0.0; + } + $this->pdo->prepare( "INSERT INTO td_invoice (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, - order_id, contact_id, issued_date, due_date, - subtotal, discount, tax, shipping_fee, grand_total, + order_id, contact_id, department_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, status, notes, `log`) VALUES (:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id, - :order_id, :contact_id, :issued_date, NULL, - :amount, 0, 0, 0, :grand_total, + :order_id, :contact_id, :department_id, :issued_date, NULL, + :amount, 0, :tax, :tax_adjustment, 0, :grand_total, 1, '', :log)" )->execute([ + // The credit note belongs to the same department as the invoice it + // corrects; it was left at 0 before. + ':department_id' => (int)($parent['department_id'] ?? 0), + ':tax' => $tax, + ':tax_adjustment' => $tax_adj, ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, @@ -926,7 +954,7 @@ class InvoiceManager { ':order_id' => (int)$parent['order_id'], ':contact_id' => (int)$parent['contact_id'], ':issued_date' => $issued_date, - ':amount' => $amount, + ':amount' => $subtotal, ':grand_total' => -abs($amount), // negative for net-balance queries ':log' => json_encode($log), ]); diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index 2fdfeb5..6aa24f7 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -406,12 +406,23 @@ class OrderManager { 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 order_id = :order_id, status = 5 + "UPDATE td_quotation SET status = 5 WHERE id = :id AND company_id = :cid AND status = 2" - )->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]); + )->execute([':id' => $quotation_id, ':cid' => $this->company_id]); } public function assertRevenueOrderEditable(int $order_id): void diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index 7d5029f..03a561b 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -122,13 +122,28 @@ class ReturnManager { $sth = $this->pdo->prepare( "INSERT INTO td_return_item (company_id, return_id, item_id, product_sku, product_name, - quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id) + quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id, + zone, aisle, bin) VALUES (:company_id, :return_id, :item_id, :product_sku, :product_name, - :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id)" + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id, + :zone, :aisle, :bin)" ); foreach ($items as $pos => $item) { + // Put-away location chosen on the form. In simple location mode + // the page sends only the bin; zone and aisle mirror it, the same + // convention stock-in and goods receipt use, because md_bin is + // looked up on all three. + $bin = trim((string)($item['bin'] ?? '')); + $zone = trim((string)($item['zone'] ?? '')); + $aisle = trim((string)($item['aisle'] ?? '')); + if ($zone === '' && $bin !== '') $zone = $bin; + if ($aisle === '' && $bin !== '') $aisle = $bin; + $sth->execute([ + ':zone' => $zone, + ':aisle' => $aisle, + ':bin' => $bin, ':company_id' => $this->company_id, ':return_id' => $return_id, ':item_id' => $pos + 1, @@ -474,6 +489,16 @@ class ReturnManager { throw new Exception("Item #{$i}: missing linked stock-out record."); } + // Returned goods are put back into a specific bin. A return saved + // before the location columns existed has none recorded; name the + // fix rather than letting occupyBin() fail on an empty location. + if (trim((string)($item['bin'] ?? '')) === '') { + $name = $item['product_name'] ?: $product_sku; + throw new Exception( + "\"{$name}\" has no return location. Open the return, choose where it goes back to, save, then confirm." + ); + } + $stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh); $stock_out_sth = $this->pdo->prepare( "SELECT status, lot_number, serial_number, price diff --git a/demo_seed_transactions.php b/demo_seed_transactions.php index 51a180d..84dff55 100644 --- a/demo_seed_transactions.php +++ b/demo_seed_transactions.php @@ -21,13 +21,11 @@ * order/api/engine/issue_invoice.php and finance/api/engine/manage_receipt.php * do it), so the data matches what the real UI would have produced. * - * NOTE — customer returns: ReturnManager::confirmReturn() reads zone/aisle/bin - * from td_return_item rows, but td_return_item has no zone/aisle/bin columns - * (see setup.php) and no engine file back-fills them before calling - * confirmReturn(). Confirming ANY customer return in this app currently - * throws "Location - - - does not exist" from WarehouseManager::occupyBin(). - * This script creates one customer return and leaves it in draft status — - * confirming it is not possible until that's fixed. + * Customer returns: the return is saved with a put-away bin and then + * confirmed, the same two steps the Customer Return page performs. (It used to + * be left in draft: td_return_item had no zone/aisle/bin columns, so the + * location was lost on save and confirmReturn() could not restock. setup.php + * now adds them.) * * Safe to re-run: every insert is guarded by an existence check. */ @@ -300,12 +298,9 @@ $quotMgmt = new QuotationManager($pdo2, $company_id); $orderMgmt = new OrderManager($pdo2, $company_id); $invMgmt = new InvoiceManager($pdo2, $company_id); -// NOTE: OrderManager::linkQuotationToOrder() is broken — it writes to a -// td_quotation.order_id column that does not exist in the schema (see -// setup.php's td_quotation definition). We never call it. The real link -// between a quotation and its order is source='quotation'/source_id=$qid on -// td_order, which QuotationManager::getById() already joins on — that's what -// this script relies on too, both to link and to look the link back up. +// The link between a quotation and its order is source='quotation' / +// source_id=$qid on td_order, which QuotationManager::getById() joins on — +// that's what this script relies on, both to link and to look the link back up. $sth = $pdo2->prepare("SELECT id FROM td_quotation WHERE company_id = :c AND notes = 'Demo seed sales cycle' LIMIT 1"); $sth->execute([':c' => $company_id]); @@ -704,10 +699,9 @@ if ($supplier_return_id) { } // ───────────────────────────────────────────────────────────────────────────── -// 10. Customer return — draft only (see NOTE at top of this file: confirming -// customer returns is currently broken in the app itself). +// 10. Customer return — saved with a put-away bin, then confirmed (restocked). // ───────────────────────────────────────────────────────────────────────────── -echo "\n--- Customer return (draft only — confirming is currently broken, see file header) ---\n"; +echo "\n--- Customer return ---\n"; $returnMgmt = new ReturnManager($pdo2, $company_id); @@ -730,7 +724,18 @@ if ($customer_return_id) { $ret_total = round($ret_qty * (float)$order_line['unit_price'], 4); $ret_tax = round($ret_total * (float)$order_line['tax_rate'] / 100, 4); - $customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging) { + // Returned goods go back into a free bin of the warehouse they shipped from. + $sth = $pdo2->prepare( + "SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL + ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1" + ); + $sth->execute([':c' => $company_id, ':w' => (int)$order_line['warehouse_id']]); + $return_bin = (string)($sth->fetchColumn() ?: ''); + if ($return_bin === '') { + throw new Exception("No free bin in warehouse #{$order_line['warehouse_id']} for the demo customer return."); + } + + $customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging, $return_bin) { return $returnMgmt->saveReturn([ 'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id, 'contact_id' => $customer_ids[0], 'return_date' => date('Y-m-d'), @@ -742,10 +747,23 @@ if ($customer_return_id) { 'total_price' => $ret_total, 'tax_amount' => $ret_tax, 'tax_rate' => $order_line['tax_rate'], 'warehouse_id' => $order_line['warehouse_id'], 'stock_out_id' => $order_line['stock_out_id'], 'stock_out_warehouse_id' => $order_line['warehouse_id'], + // simple location mode: zone and aisle mirror the bin + 'zone' => $return_bin, 'aisle' => $return_bin, 'bin' => $return_bin, ]], ], $logging); }); - ok("Created draft customer return for {$ret_qty} x {$order_line['product_sku']} (id={$customer_return_id}) — left in draft, not confirmed"); + + // Confirm exactly as order/api/engine/confirm_return.php does: restock, + // auto-approve the stock-in, and raise the credit note. + dbTransaction($pdo2, function ($pdo2) use ($company_id, $customer_return_id, $logging) { + $ret = new ReturnManager($pdo2, $company_id); + $ret->confirmReturn( + $customer_return_id, bin2hex(random_bytes(16)), $logging, + new WarehouseManager($pdo2, $company_id), new InvoiceManager($pdo2, $company_id), + true, true + ); + }); + ok("Created + confirmed customer return for {$ret_qty} x {$order_line['product_sku']} into bin {$return_bin} (id={$customer_return_id})"); } // ───────────────────────────────────────────────────────────────────────────── diff --git a/setup.php b/setup.php index cae7cfe..3a2a666 100644 --- a/setup.php +++ b/setup.php @@ -872,12 +872,24 @@ CREATE TABLE IF NOT EXISTS `td_return_item` ( `warehouse_id` int(11) NOT NULL DEFAULT 0, `stock_out_id` int(11) NOT NULL DEFAULT 0, `stock_out_warehouse_id` int(11) NOT NULL DEFAULT 0, + `zone` varchar(255) NOT NULL DEFAULT '', + `aisle` varchar(255) NOT NULL DEFAULT '', + `bin` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uq_return_item` (`return_id`,`item_id`), KEY `idx_return` (`company_id`,`return_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ", 'td_return_item'); +// Put-away location of each returned line. The Customer Return form has always +// asked for it and ReturnManager::confirmReturn() has always read it, but the +// table had nowhere to keep it: the choice was dropped on save and every +// confirmation then failed in WarehouseManager::occupyBin() with +// "Bin -- does not exist". Same widths as td_stock's location columns. +run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `zone` varchar(255) NOT NULL DEFAULT '' AFTER `stock_out_warehouse_id`", 'td_return_item.zone'); +run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `aisle` varchar(255) NOT NULL DEFAULT '' AFTER `zone`", 'td_return_item.aisle'); +run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `bin` varchar(255) NOT NULL DEFAULT '' AFTER `aisle`", 'td_return_item.bin'); + run($pdo, " CREATE TABLE IF NOT EXISTS `td_purchase_request` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT,