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.
This commit is contained in:
@@ -905,18 +905,46 @@ class InvoiceManager {
|
|||||||
|
|
||||||
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
|
$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(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO td_invoice
|
"INSERT INTO td_invoice
|
||||||
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
|
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
|
||||||
order_id, contact_id, issued_date, due_date,
|
order_id, contact_id, department_id, issued_date, due_date,
|
||||||
subtotal, discount, tax, shipping_fee, grand_total,
|
subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total,
|
||||||
status, notes, `log`)
|
status, notes, `log`)
|
||||||
VALUES
|
VALUES
|
||||||
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
|
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
|
||||||
:order_id, :contact_id, :issued_date, NULL,
|
:order_id, :contact_id, :department_id, :issued_date, NULL,
|
||||||
:amount, 0, 0, 0, :grand_total,
|
:amount, 0, :tax, :tax_adjustment, 0, :grand_total,
|
||||||
1, '', :log)"
|
1, '', :log)"
|
||||||
)->execute([
|
)->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,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
@@ -926,7 +954,7 @@ class InvoiceManager {
|
|||||||
':order_id' => (int)$parent['order_id'],
|
':order_id' => (int)$parent['order_id'],
|
||||||
':contact_id' => (int)$parent['contact_id'],
|
':contact_id' => (int)$parent['contact_id'],
|
||||||
':issued_date' => $issued_date,
|
':issued_date' => $issued_date,
|
||||||
':amount' => $amount,
|
':amount' => $subtotal,
|
||||||
':grand_total' => -abs($amount), // negative for net-balance queries
|
':grand_total' => -abs($amount), // negative for net-balance queries
|
||||||
':log' => json_encode($log),
|
':log' => json_encode($log),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -406,12 +406,23 @@ class OrderManager {
|
|||||||
return $returnable;
|
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
|
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
|
||||||
{
|
{
|
||||||
$this->pdo->prepare(
|
$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"
|
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
|
public function assertRevenueOrderEditable(int $order_id): void
|
||||||
|
|||||||
@@ -122,13 +122,28 @@ class ReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"INSERT INTO td_return_item
|
"INSERT INTO td_return_item
|
||||||
(company_id, return_id, item_id, product_sku, product_name,
|
(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
|
VALUES
|
||||||
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
(: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) {
|
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([
|
$sth->execute([
|
||||||
|
':zone' => $zone,
|
||||||
|
':aisle' => $aisle,
|
||||||
|
':bin' => $bin,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':return_id' => $return_id,
|
':return_id' => $return_id,
|
||||||
':item_id' => $pos + 1,
|
':item_id' => $pos + 1,
|
||||||
@@ -474,6 +489,16 @@ class ReturnManager {
|
|||||||
throw new Exception("Item #{$i}: missing linked stock-out record.");
|
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_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
|
||||||
$stock_out_sth = $this->pdo->prepare(
|
$stock_out_sth = $this->pdo->prepare(
|
||||||
"SELECT status, lot_number, serial_number, price
|
"SELECT status, lot_number, serial_number, price
|
||||||
|
|||||||
+36
-18
@@ -21,13 +21,11 @@
|
|||||||
* order/api/engine/issue_invoice.php and finance/api/engine/manage_receipt.php
|
* 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.
|
* do it), so the data matches what the real UI would have produced.
|
||||||
*
|
*
|
||||||
* NOTE — customer returns: ReturnManager::confirmReturn() reads zone/aisle/bin
|
* Customer returns: the return is saved with a put-away bin and then
|
||||||
* from td_return_item rows, but td_return_item has no zone/aisle/bin columns
|
* confirmed, the same two steps the Customer Return page performs. (It used to
|
||||||
* (see setup.php) and no engine file back-fills them before calling
|
* be left in draft: td_return_item had no zone/aisle/bin columns, so the
|
||||||
* confirmReturn(). Confirming ANY customer return in this app currently
|
* location was lost on save and confirmReturn() could not restock. setup.php
|
||||||
* throws "Location - - - does not exist" from WarehouseManager::occupyBin().
|
* now adds them.)
|
||||||
* This script creates one customer return and leaves it in draft status —
|
|
||||||
* confirming it is not possible until that's fixed.
|
|
||||||
*
|
*
|
||||||
* Safe to re-run: every insert is guarded by an existence check.
|
* 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);
|
$orderMgmt = new OrderManager($pdo2, $company_id);
|
||||||
$invMgmt = new InvoiceManager($pdo2, $company_id);
|
$invMgmt = new InvoiceManager($pdo2, $company_id);
|
||||||
|
|
||||||
// NOTE: OrderManager::linkQuotationToOrder() is broken — it writes to a
|
// The link between a quotation and its order is source='quotation' /
|
||||||
// td_quotation.order_id column that does not exist in the schema (see
|
// source_id=$qid on td_order, which QuotationManager::getById() joins on —
|
||||||
// setup.php's td_quotation definition). We never call it. The real link
|
// that's what this script relies on, both to link and to look the link back up.
|
||||||
// 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.
|
|
||||||
|
|
||||||
$sth = $pdo2->prepare("SELECT id FROM td_quotation WHERE company_id = :c AND notes = 'Demo seed sales cycle' LIMIT 1");
|
$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]);
|
$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
|
// 10. Customer return — saved with a put-away bin, then confirmed (restocked).
|
||||||
// customer returns is currently broken in the app itself).
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
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);
|
$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_total = round($ret_qty * (float)$order_line['unit_price'], 4);
|
||||||
$ret_tax = round($ret_total * (float)$order_line['tax_rate'] / 100, 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([
|
return $returnMgmt->saveReturn([
|
||||||
'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id,
|
'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id,
|
||||||
'contact_id' => $customer_ids[0], 'return_date' => date('Y-m-d'),
|
'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'],
|
'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'],
|
'warehouse_id' => $order_line['warehouse_id'], 'stock_out_id' => $order_line['stock_out_id'],
|
||||||
'stock_out_warehouse_id' => $order_line['warehouse_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);
|
], $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})");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -872,12 +872,24 @@ CREATE TABLE IF NOT EXISTS `td_return_item` (
|
|||||||
`warehouse_id` int(11) NOT NULL DEFAULT 0,
|
`warehouse_id` int(11) NOT NULL DEFAULT 0,
|
||||||
`stock_out_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,
|
`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`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uq_return_item` (`return_id`,`item_id`),
|
UNIQUE KEY `uq_return_item` (`return_id`,`item_id`),
|
||||||
KEY `idx_return` (`company_id`,`return_id`)
|
KEY `idx_return` (`company_id`,`return_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
", 'td_return_item');
|
", '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, "
|
run($pdo, "
|
||||||
CREATE TABLE IF NOT EXISTS `td_purchase_request` (
|
CREATE TABLE IF NOT EXISTS `td_purchase_request` (
|
||||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
Reference in New Issue
Block a user