diff --git a/app/accounting/journal_listing.php b/app/accounting/journal_listing.php index 6737423..6ef12d7 100644 --- a/app/accounting/journal_listing.php +++ b/app/accounting/journal_listing.php @@ -523,7 +523,7 @@ var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0) ? '
Reference
' + escape_html(h.reference || ('MJE-' + h.id)) + '
' + - '
Journal Date
' + escape_html(h.journal_date_fmt || '—') + '
' + + '
Journal Date
' + escape_html(format_date(h.journal_date)) + '
' + '
Description
' + escape_html(h.description || '—') + '
' : '
Formula
' + escape_html(h.formula_name) + '
' + '
Version
v' + h.current_version + (h.current_version > 1 ? ' (replaced)' : '') + '
' + diff --git a/app/asset/manage_asset.php b/app/asset/manage_asset.php index 1cad5f2..2f88466 100644 --- a/app/asset/manage_asset.php +++ b/app/asset/manage_asset.php @@ -329,7 +329,7 @@ invoice_line = res.output; var l = invoice_line; $('#asset_name').val(l.product_name || l.product_sku); - $('#acquisition_date, #in_service_date').val(format_date(l.issued_date)); + $('#acquisition_date, #in_service_date').val(format_date_input(l.issued_date)); $('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2)); $('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —')); load_departments('department_id', l.department_id); @@ -360,8 +360,8 @@ fill_category_select(a.category_id); update_class_hint(); $('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —')); - $('#acquisition_date').val(format_date(a.acquisition_date)); - $('#in_service_date').val(format_date(a.in_service_date)); + $('#acquisition_date').val(format_date_input(a.acquisition_date)); + $('#in_service_date').val(format_date_input(a.in_service_date)); $('#cost').val(parseFloat(a.cost).toFixed(2)); $('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2)); $('#useful_life_months').val(a.useful_life_months); diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 0a54c2e..3d7aea4 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -995,15 +995,24 @@ document.addEventListener('DOMContentLoaded', () => { /** * Helper function to format date strings (assuming input is in ISO format) */ +// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time. function format_date(iso_string) { if (!iso_string) return '—'; - var split = iso_string.split(" "); + var split = String(iso_string).split(" "); var datePart = split[0].split("-"); if (datePart.length !== 3) return iso_string; - var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`; + var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`; return split.length === 2 ? `${formatted} ${split[1]}` : formatted; } +// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it. +function format_date_input(iso_string) { + if (!iso_string) return ''; + var datePart = String(iso_string).split(" ")[0].split("-"); + if (datePart.length !== 3) return iso_string; + return `${datePart[2]}/${datePart[1]}/${datePart[0]}`; +} + function to_iso_date(dateStr) { if (!dateStr) return null; diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index bc3d6b5..ebf2de9 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -79,7 +79,8 @@ class StockManager { public function getStockList(int $warehouse_id, string $type): array { $table = $this->stockTableNameFromWarehouseId($warehouse_id); - $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; + // The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0). + $column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; $stock_ref = $this->stockReferenceSql(); // Transfer list: show only the outbound side (out > 0) to avoid duplicate display @@ -439,8 +440,9 @@ class StockManager { ); } - // Quantity and identifiers come from the existing stock_in row (immutable) - $quantity = (int)$source_stock['in']; + // Quantity and identifiers come from the existing stock_in row (immutable). + // `in` is decimal(18,4) — an int cast would drop fractional quantities. + $quantity = (float)$source_stock['in']; $ref_id = (int)$source_stock['id']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; @@ -611,8 +613,9 @@ class StockManager { ); } - // Quantity and identifiers come from the source stock_in row (immutable) - $quantity = (int)$source_stock['in']; + // Quantity and identifiers come from the source stock_in row (immutable). + // `in` is decimal(18,4) — an int cast would drop fractional quantities. + $quantity = (float)$source_stock['in']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php index c533201..9c2c572 100644 --- a/app/assets/utils/classes_ac/FinancialReports.php +++ b/app/assets/utils/classes_ac/FinancialReports.php @@ -697,7 +697,7 @@ class FinancialReports COALESCE(d.dept_code, '') AS dept_code, COALESCE(d.dept_name, '') AS dept_name, COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, i.debit, i.credit, COALESCE(i.description, '') AS line_description diff --git a/app/assets/utils/classes_ac/GlQueryManager.php b/app/assets/utils/classes_ac/GlQueryManager.php index c58d2f5..25df724 100644 --- a/app/assets/utils/classes_ac/GlQueryManager.php +++ b/app/assets/utils/classes_ac/GlQueryManager.php @@ -84,8 +84,8 @@ class GlQueryManager g.current_version, g.formula_id, COALESCE(f.formula_name, '') AS formula_name, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, - DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, + DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at, CASE g.source_type WHEN 'receipt' THEN r.receipt_number WHEN 'payment' THEN p.payment_number @@ -160,8 +160,8 @@ class GlQueryManager $sth = $this->pdo->prepare( "SELECT g.*, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, - DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, + DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt, DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt, COALESCE(f.formula_name, '') AS formula_name FROM td_gl g diff --git a/app/assets/utils/classes_as/AssetManager.php b/app/assets/utils/classes_as/AssetManager.php index 6b92b41..5b1b29c 100644 --- a/app/assets/utils/classes_as/AssetManager.php +++ b/app/assets/utils/classes_as/AssetManager.php @@ -541,7 +541,7 @@ class AssetManager { $sth = $this->pdo->prepare( "SELECT id, reference, journal_date, period, current_version, - DATE_FORMAT(created_at, '%d/%m/%Y %H:%i') AS posted_at + DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]); diff --git a/app/assets/utils/classes_as/AssetRunManager.php b/app/assets/utils/classes_as/AssetRunManager.php index fe78046..dab6303 100644 --- a/app/assets/utils/classes_as/AssetRunManager.php +++ b/app/assets/utils/classes_as/AssetRunManager.php @@ -179,7 +179,7 @@ class AssetRunManager $sth = $this->pdo->prepare( "SELECT r.*, - DATE_FORMAT(r.created_at, '%d/%m/%Y %H:%i') AS created_at_fmt, + DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i:%s') AS created_at_fmt, IF(g.id IS NULL, 0, 1) AS gl_status, COALESCE(g.id, 0) AS gl_id FROM td_asset_run r diff --git a/app/expense/manage_purchase_invoice.php b/app/expense/manage_purchase_invoice.php index d6f1072..9dd1f74 100644 --- a/app/expense/manage_purchase_invoice.php +++ b/app/expense/manage_purchase_invoice.php @@ -294,7 +294,7 @@ $('#badge_status').html(invoice_status_badge(inv.status, inv.due_date)); $('#display_contact').text(inv.contact_name || '—'); $('#display_issued').text(format_date(inv.issued_date) || '—'); - $('#due_date').val(inv.due_date ? format_date(inv.due_date) : ''); + $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#notes').val(inv.notes || ''); // Source link diff --git a/app/expense/manage_purchase_order.php b/app/expense/manage_purchase_order.php index 258be84..86986ca 100644 --- a/app/expense/manage_purchase_order.php +++ b/app/expense/manage_purchase_order.php @@ -274,8 +274,8 @@ $('#po_number_display').text(po_data.po_number || ''); $('#contact').val(po_data.contact_name || ''); $('#contact_id').val(po_data.contact_id || ''); - $('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : ''); - $('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : ''); + $('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : ''); + $('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : ''); $('#department_id').val(po_data.department_id || 0); $('#notes').val(po_data.notes || ''); $('#discount').val(po_data.discount || 0); diff --git a/app/expense/manage_purchase_request.php b/app/expense/manage_purchase_request.php index c4b1c07..08f4db4 100644 --- a/app/expense/manage_purchase_request.php +++ b/app/expense/manage_purchase_request.php @@ -253,8 +253,8 @@ .html(request_data.po_id > 0 ? 'PO: ' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '' : ''); $('#contact').val(request_data.contact_name || ''); $('#contact_id').val(request_data.contact_id || 0); - $('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : ''); - $('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : ''); + $('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : ''); + $('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : ''); $('#department_id').val(request_data.department_id || 0); $('#notes').val(request_data.notes || ''); $('#discount').val(request_data.discount || 0); diff --git a/app/ics/api/engine/retrieve_bin_stock.php b/app/ics/api/engine/retrieve_bin_stock.php new file mode 100644 index 0000000..1736df9 --- /dev/null +++ b/app/ics/api/engine/retrieve_bin_stock.php @@ -0,0 +1,38 @@ +getBinStock( + (int)($data['warehouse'] ?? 0), + $data['zone'] ?? '', + $data['aisle'] ?? '', + $data['bin'] ?? '' + ); + + $output = null; + if ($row) { + $sth = $pdo2->prepare("SELECT uom FROM md_product WHERE company_id = :c AND sku = :sku LIMIT 1"); + $sth->execute([':c' => $company_id, ':sku' => $row['product_sku']]); + $output = [ + 'product_sku' => $row['product_sku'], + 'lot_number' => $row['lot_number'], + 'serial_number' => $row['serial_number'], + 'quantity' => (float)$row['in'], + 'uom' => (string)($sth->fetchColumn() ?: ''), + ]; + } + + $answer['output'] = $output; + $answer['success'] = 1; + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 5f64dfc..fe893e3 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -93,6 +93,16 @@ + +
+ +
+ +   +
+
The whole quantity in the selected location is taken out.
+
+
@@ -159,6 +169,35 @@ } set_select_value('#bin', data.bin, data.bin); scan_location_ready = true; + show_location_quantity(); + }); + } + + // Quantity held in the chosen location — the amount this stock-out moves. + function show_location_quantity() { + var bin_val = $('#bin').val(); + $('#location_quantity').val(''); + $('#location_uom').html(' '); + if (!$('#warehouse').val() || !bin_val) return; + + return ajax_request({ + url: 'ics/api/engine/retrieve_bin_stock.php', + autoPrepare: true, + checkRequired: 0, + noLoading: true, + queueLock: false, + action: 'read', + data: { + warehouse: $('#warehouse').val(), + zone: advanced ? $('#zone').val() : bin_val, + aisle: advanced ? $('#aisle').val() : bin_val, + bin: bin_val + }, + onSuccess: function(res) { + if (!res.output) return; + $('#location_quantity').val(format_number(res.output.quantity, 2)); + $('#location_uom').text(res.output.uom || ''); + } }); } @@ -483,6 +522,8 @@ $('#zone').html('').prop('disabled', true); $('#aisle').html('').prop('disabled', true); $('#bin').html('').prop('disabled', true); + $('#location_quantity').val(''); + $('#location_uom').html(' '); } @@ -509,7 +550,7 @@ $('#serial_number').html(``) .val(data.serial_number || '').prop('disabled', true); - $('#warehouse').val('').prop('disabled', true); + $('#warehouse').val('').prop('disabled', true); function populate_fields() { $.each(data, function(key, item) { @@ -522,6 +563,8 @@ .attr('data-id', data.contact_id); $('#product_name').val(data.product_name); $('#product_sku').attr('secondary', data.product_sku).prop('disabled', true); + $('#location_quantity').val(format_number(data.quantity, 2)); + $('#location_uom').text(data.uom || ''); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); if (data.status == 0) { @@ -619,6 +662,9 @@ if (advanced) retrieve_bin(); }); + // Bin selected → show how much it holds + $('#bin').on('change', show_location_quantity); + diff --git a/app/ics/manage_stock_transfer.php b/app/ics/manage_stock_transfer.php index 64ff825..61da8c0 100644 --- a/app/ics/manage_stock_transfer.php +++ b/app/ics/manage_stock_transfer.php @@ -92,6 +92,15 @@
+ +
+ +
+ +   +
+
Whole location is moved.
+

@@ -204,12 +213,41 @@ if (role === 'from') { scan_from_ready = true; + show_from_quantity(); } else { scan_to_ready = true; } }); } + // Quantity held in the from-location — the amount this transfer moves. + function show_from_quantity() { + var bin_val = $('#bin_from').val(); + $('#transfer_quantity').val(''); + $('#transfer_uom').html(' '); + if (!$('#warehouse_from').val() || !bin_val) return; + + return ajax_request({ + url: 'ics/api/engine/retrieve_bin_stock.php', + autoPrepare: true, + checkRequired: 0, + noLoading: true, + queueLock: false, + action: 'read', + data: { + warehouse: $('#warehouse_from').val(), + zone: advanced ? $('#zone_from').val() : bin_val, + aisle: advanced ? $('#aisle_from').val() : bin_val, + bin: bin_val + }, + onSuccess: function(res) { + if (!res.output) return; + $('#transfer_quantity').val(format_number(res.output.quantity, 2)); + $('#transfer_uom').text(res.output.uom || ''); + } + }); + } + function same_location_as_from(data) { return String($('#warehouse_from').val()) === String(data.warehouse_id) && String($('#zone_from').val()) === String(data.zone) @@ -574,6 +612,8 @@ $('#zone_from').html('').prop('disabled', true); $('#aisle_from').html('').prop('disabled', true); $('#bin_from').html('').prop('disabled', true); + $('#transfer_quantity').val(''); + $('#transfer_uom').html(' '); } @@ -640,6 +680,8 @@ $('#contact').val(data.contact_name) .attr('secondary', data.contact_name) .attr('data-id', data.contact_id); + $('#transfer_quantity').val(format_number(data.quantity, 2)); + $('#transfer_uom').text(data.uom || ''); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); if (data.status == 0) { @@ -732,6 +774,9 @@ if (advanced) retrieve_bin('from'); }); + // From bin → show how much it holds + $('#bin_from').on('change', show_from_quantity); + // To warehouse → zone (advanced) or bin directly (simple) $('select[name="warehouse"][role="to"]').on('change', function() { if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); } diff --git a/app/journal/index.php b/app/journal/index.php index 5c1bed1..bcc943c 100644 --- a/app/journal/index.php +++ b/app/journal/index.php @@ -199,7 +199,7 @@ document.getElementById('gl_detail_body').innerHTML = '
' + - '

Date

' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '
' + + '

Date

' + escape_html(format_date(h.journal_date)) + '
' + '

Period

' + escape_html(h.period) + '
' + '

Reference

' + escape_html(h.reference || '—') + '
' + '

Source

' + escape_html(src_labels[h.source_type] || h.source_type) + '
' + diff --git a/app/order/manage_invoice.php b/app/order/manage_invoice.php index 9476c67..54f4f69 100644 --- a/app/order/manage_invoice.php +++ b/app/order/manage_invoice.php @@ -250,7 +250,7 @@ $('#display_contact').text(inv.contact_name || '—'); $('#display_department').text(get_dept_label(inv.department_id)); $('#display_issued').text(format_date(inv.issued_date) || '—'); - $('#due_date').val(inv.due_date ? format_date(inv.due_date) : ''); + $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#notes').val(inv.notes || ''); var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice'; diff --git a/app/order/manage_order.php b/app/order/manage_order.php index 0a8834f..f2121e3 100644 --- a/app/order/manage_order.php +++ b/app/order/manage_order.php @@ -493,7 +493,7 @@ // Fields $('#contact').val(o.contact_name || ''); $('#contact_id').val(o.contact_id); - $('#order_date').val(o.order_date ? format_date(o.order_date) : ''); + $('#order_date').val(o.order_date ? format_date_input(o.order_date) : ''); $('#department_id').val(o.department_id || 0); $('#notes').val(o.notes || ''); $('#discount').val(o.discount || 0); diff --git a/app/order/manage_return.php b/app/order/manage_return.php index e2c06db..5de63f7 100644 --- a/app/order/manage_return.php +++ b/app/order/manage_return.php @@ -795,7 +795,7 @@ $('#badge_status').html(return_status_badge(r.status)); $('#return_number_display').text(r.return_number); - $('#return_date').val(r.return_date ? format_date(r.return_date) : ''); + $('#return_date').val(r.return_date ? format_date_input(r.return_date) : ''); $('#reason').val(r.reason || ''); $('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2)); $('#display_department').text(get_dept_label(r.department_id)); diff --git a/app/po/manage_po.php b/app/po/manage_po.php index 7fa1b83..a04c39f 100644 --- a/app/po/manage_po.php +++ b/app/po/manage_po.php @@ -682,8 +682,8 @@ $('#contact').val(o.contact_name || ''); $('#contact_id').val(o.contact_id || ''); - $('#po_date').val(o.po_date ? format_date(o.po_date) : ''); - $('#expected_date').val(o.expected_date ? format_date(o.expected_date) : ''); + $('#po_date').val(o.po_date ? format_date_input(o.po_date) : ''); + $('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : ''); $('#warehouse_id').val(o.warehouse_id || ''); $('#department_id').val(o.department_id || 0); $('#notes').val(o.notes || ''); diff --git a/app/po/manage_purchase_invoice.php b/app/po/manage_purchase_invoice.php index 44e16d0..c33747f 100644 --- a/app/po/manage_purchase_invoice.php +++ b/app/po/manage_purchase_invoice.php @@ -226,7 +226,7 @@ $('#display_contact').text(inv.contact_name || '—'); $('#display_department').text(get_dept_label(inv.department_id)); $('#display_issued').text(format_date(inv.issued_date) || '—'); - $('#due_date').val(inv.due_date ? format_date(inv.due_date) : ''); + $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#notes').val(inv.notes || ''); // Source link diff --git a/app/po/manage_supplier_return.php b/app/po/manage_supplier_return.php index c6db61b..317a9ad 100644 --- a/app/po/manage_supplier_return.php +++ b/app/po/manage_supplier_return.php @@ -432,7 +432,7 @@ $('#badge_status').html(return_status_badge(r.status)); $('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status)); $('#return_number_display').text(r.return_number); - $('#return_date').val(r.return_date ? format_date(r.return_date) : ''); + $('#return_date').val(r.return_date ? format_date_input(r.return_date) : ''); $('#reason').val(r.reason || ''); $('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2)); $('#display_department').text(get_dept_label(r.department_id)); diff --git a/app/revenue/manage_invoice.php b/app/revenue/manage_invoice.php index 9042597..113298d 100644 --- a/app/revenue/manage_invoice.php +++ b/app/revenue/manage_invoice.php @@ -207,7 +207,7 @@ $('#display_contact').html(display_text(inv.contact_name)); $('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '—'); $('#display_due').html(inv.due_date ? format_date(inv.due_date) : '—'); - $('#due_date').val(inv.due_date ? format_date(inv.due_date) : ''); + $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '
') : '—'); var rows = ''; diff --git a/app/revenue/manage_order.php b/app/revenue/manage_order.php index bd4bbab..8d3f93a 100644 --- a/app/revenue/manage_order.php +++ b/app/revenue/manage_order.php @@ -356,7 +356,7 @@ $('#order_number_display').text(o.order_number); $('#contact').val(o.contact_name || ''); $('#contact_id').val(o.contact_id || 0); - $('#order_date').val(o.order_date ? format_date(o.order_date) : ''); + $('#order_date').val(o.order_date ? format_date_input(o.order_date) : ''); $('#notes').val(o.notes || ''); $('#discount').val(o.discount || 0); $('#tax_adjustment').val(o.tax_adjustment || 0); diff --git a/app/revenue/manage_quotation.php b/app/revenue/manage_quotation.php index b4f0ef9..dffbc13 100644 --- a/app/revenue/manage_quotation.php +++ b/app/revenue/manage_quotation.php @@ -379,8 +379,8 @@ $('#contact').val(q.contact_name || ''); $('#contact_id').val(q.contact_id || 0); - $('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : ''); - $('#valid_until').val(q.valid_until ? format_date(q.valid_until) : ''); + $('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : ''); + $('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : ''); $('#department_id').val(q.department_id || 0); $('#notes').val(q.notes || ''); $('#discount').val(parseFloat(q.discount) || 0); diff --git a/app/setting/users.php b/app/setting/users.php index 1ca5f2b..9b394bd 100644 --- a/app/setting/users.php +++ b/app/setting/users.php @@ -257,9 +257,7 @@ const license_badge = license_html(u.license); const access_badge = app_access_html(u.app_access); - const joined = u.created_at - ? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'}) - : '—'; + const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—'; const is_pending = u.status === 'pending' && u.is_pending_invite == 1; diff --git a/demo_seed.php b/demo_seed.php index 5ad4daf..6344f26 100644 --- a/demo_seed.php +++ b/demo_seed.php @@ -532,9 +532,11 @@ foreach ($stockout_defs as $so) { $qty = $so['qty']; $cost = $p['cost']; $uom = $p['uom']; + // The batch is bought in from a supplier; only the stock-out goes to the customer. + $supplier_id = $supplier_ids[0]; dbTransaction($pdo2, function ($pdo2) use ( - $stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, + $stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id, $logging, $dispatch_in_marker, $dispatch_out_marker ) { $bin = findFreeBin($pdo2, $company_id, $main_wh_id); @@ -549,7 +551,7 @@ foreach ($stockout_defs as $so) { 'zone' => $bin, 'aisle' => $bin, 'bin' => $bin, - 'contact_id' => $customer_id, + 'contact_id' => $supplier_id, 'description' => $dispatch_in_marker, ], $logging, $in_uuid); $stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt); diff --git a/demo_seed_assets.php b/demo_seed_assets.php new file mode 100644 index 0000000..ceba3d1 --- /dev/null +++ b/demo_seed_assets.php @@ -0,0 +1,374 @@ + confirm -> purchase + * invoice -> issue -> GL post (PurchaseOrderManager, InvoiceManager), the + * only way a purchase invoice exists in the app + * 3. Assets capitalized from those invoice lines, plus two vehicles bought + * before the system was used and already on the books (AssetManager) + * 4. Depreciation runs up to last month, the sale of the forklift today, then + * this month's depreciation and amortization runs (AssetRunManager) + * 5. GL posting of the purchase invoice, capitalizations, the disposal and every + * run before this month (AssetPosting + GlManager, as + * accounting/api/engine/post_gl_entry.php does) — this month's runs stay + * unposted so Batch GL Entries has Asset Management work to show. + * + * Dates are relative to today: the app stamps documents with the current date. + * + * Safe to re-run: every step checks what already exists. + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('Run via CLI only: php demo_seed_assets.php'); +} + +$_SESSION = []; + +require_once __DIR__ . '/app/config.php'; +require_once __DIR__ . '/app/dbconn.php'; +require_once __DIR__ . '/app/assets/utils/db_helpers.php'; +require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php'; +require_once __DIR__ . '/app/assets/utils/classes/StockManager.php'; +require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php'; +require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php'; +require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php'; +require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php'; +require_once __DIR__ . '/app/assets/utils/classes_ac/PostingWindowGuard.php'; +require_once __DIR__ . '/app/assets/utils/classes_ac/GlManager.php'; +require_once __DIR__ . '/app/assets/utils/classes_ac/posting/BasePosting.php'; +require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php'; +require_once __DIR__ . '/app/assets/utils/classes_as/AssetManager.php'; +require_once __DIR__ . '/app/assets/utils/classes_as/AssetRunManager.php'; +require_once __DIR__ . '/app/assets/utils/classes_as/AssetPosting.php'; + +function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; } +function ok(string $msg): void { echo "\033[32m ✓\033[0m {$msg}\n"; } +function skip(string $msg): void { echo "\033[33m –\033[0m {$msg}\n"; } + +echo "\n\033[1m=== brnwms Demo Asset Management Seeder ===\033[0m\n\n"; + +// ───────────────────────────────────────────────────────────────────────────── +// 0. Resolve what the earlier seeders created +// ───────────────────────────────────────────────────────────────────────────── +$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1"); +$sth->execute(); +$company_id = (int)($sth->fetchColumn() ?: 0); +if (!$company_id) exit("ERROR: demo company not found — run demo_seed.php first.\n"); + +$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = 'admin' LIMIT 1"); +$sth->execute(); +$owner_user_id = (int)($sth->fetchColumn() ?: 0); + +$lookup = function (string $sql, array $params) use ($pdo2): int { + $sth = $pdo2->prepare($sql); + $sth->execute($params); + return (int)($sth->fetchColumn() ?: 0); +}; + +$main_wh = $lookup("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = 'Main Warehouse' LIMIT 1", [':c' => $company_id]); +$supplier_id = $lookup("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = 'Asia Electronics Wholesale' LIMIT 1", [':c' => $company_id]); +$wh_dept_id = $lookup("SELECT id FROM md_department WHERE company_id = :c AND dept_code = 'WH' LIMIT 1", [':c' => $company_id]); +if (!$main_wh || !$supplier_id || !$wh_dept_id) { + exit("ERROR: warehouse, supplier or WH department missing — run demo_seed.php and demo_seed_transactions.php first.\n"); +} + +$coa = new ChartOfAccounts($pdo2, $company_id); +foreach (['1000', '4000', '5000'] as $code) { + if (!$coa->getByCode($code)) exit("ERROR: account {$code} missing — run demo_seed_transactions.php first.\n"); +} + +$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_assets']; +$today = date('Y-m-d'); +$this_period = date('Y-m'); +$last_period = DepreciationCalculator::addMonths($this_period, -1); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Accounts + asset categories +// ───────────────────────────────────────────────────────────────────────────── +echo "--- Asset accounts ---\n"; + +$account_defs = [ + ['code' => '1500', 'name' => 'Equipment and Vehicles', 'type' => 'asset'], + ['code' => '1590', 'name' => 'Accumulated Depreciation', 'type' => 'asset'], + ['code' => '1600', 'name' => 'Software and Licences', 'type' => 'asset'], + ['code' => '1690', 'name' => 'Accumulated Amortization', 'type' => 'asset'], + ['code' => '4900', 'name' => 'Gain on Asset Disposal', 'type' => 'revenue'], + ['code' => '6100', 'name' => 'Depreciation Expense', 'type' => 'expense'], + ['code' => '6200', 'name' => 'Amortization Expense', 'type' => 'expense'], + ['code' => '6900', 'name' => 'Loss on Asset Disposal', 'type' => 'expense'], +]; +foreach ($account_defs as $a) { + if ($coa->getByCode($a['code'])) { + skip("Account {$a['code']} — {$a['name']} already exists"); + continue; + } + $coa->create([ + 'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'], + 'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1, + ]); + ok("Created account {$a['code']} — {$a['name']} ({$a['type']})"); +} + +echo "\n--- Asset categories ---\n"; + +$category_defs = [ + ['code' => 'IT', 'name' => 'Computer Equipment', 'class' => 'tangible', 'life' => 36, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'], + ['code' => 'VEH', 'name' => 'Vehicles and Forklifts', 'class' => 'tangible', 'life' => 60, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'], + ['code' => 'SW', 'name' => 'Software Licences', 'class' => 'intangible', 'life' => 24, 'cost' => '1600', 'accum' => '1690', 'expense' => '6200'], +]; +$category_ids = []; +foreach ($category_defs as $c) { + $id = $lookup("SELECT id FROM md_asset_category WHERE company_id = :c AND category_code = :code LIMIT 1", [':c' => $company_id, ':code' => $c['code']]); + if ($id) { + skip("Category {$c['code']} already exists (id={$id})"); + } else { + $id = (new AssetCategoryManager($pdo2, $company_id))->save([ + 'category_code' => $c['code'], 'category_name' => $c['name'], 'asset_class' => $c['class'], + 'useful_life_months' => $c['life'], 'status' => 1, 'description' => '', + 'cost_account_code' => $c['cost'], 'accum_account_code' => $c['accum'], 'expense_account_code' => $c['expense'], + 'gain_account_code' => '4900', 'loss_account_code' => '6900', + ]); + ok("Created category {$c['code']} — {$c['name']} ({$c['class']}, {$c['life']} months)"); + } + $category_ids[$c['code']] = $id; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Purchase: PO -> confirm -> purchase invoice -> issue -> GL +// ───────────────────────────────────────────────────────────────────────────── +echo "\n--- Asset purchase (PO -> purchase invoice) ---\n"; + +$prodMgmt = new ProductManager($pdo2, $company_id); + +$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]); +if (!$cat_id) { + dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveCategory([ + 'id' => 0, 'category' => 'Fixed Assets', 'slug' => 'fixed-assets', 'description' => '', 'status' => 1, + ], $logging)); + $cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]); + ok("Created product category 'Fixed Assets' (id={$cat_id})"); +} + +$asset_product_defs = [ + ['sku' => 'FA-LAPTOP', 'name' => 'Laptop - Dell Latitude 5440', 'price' => 32000], + ['sku' => 'FA-ERP', 'name' => 'ERP Software Licence (2 years)', 'price' => 48000], +]; +$products = []; +foreach ($asset_product_defs as $p) { + $id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]); + if ($id) { + skip("Product {$p['sku']} already exists"); + } else { + dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveProduct([ + 'id' => 0, 'product_name' => $p['name'], 'sku' => $p['sku'], 'barcode' => '', 'uom' => 'pcs', + 'price' => $p['price'], 'cost_price' => $p['price'], 'min_stock' => 0, 'reorder_point' => 0, + 'category' => $cat_id, 'description' => 'Bought as a fixed asset (demo seed)', 'status' => 1, + ], $logging, '')); + $id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]); + // Same accounts as the other demo products: the purchase posts to 5000, and the + // capitalization entry reclassifies it from there to the asset account. + dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->updateAccountMapping($id, '4000', '5000', $logging)); + ok("Created product {$p['sku']} — {$p['name']} (sales 4000, purchase 5000)"); + } + $products[$p['sku']] = $p; +} + +$po_marker = 'Demo seed asset purchase'; +$po_id = $lookup("SELECT id FROM td_purchase_order WHERE company_id = :c AND notes = :n LIMIT 1", [':c' => $company_id, ':n' => $po_marker]); +if ($po_id) { + skip("Asset purchase PO already exists (id={$po_id})"); +} else { + $po_items = []; + foreach ([['FA-LAPTOP', 3], ['FA-ERP', 1]] as $i => [$sku, $qty]) { + $total = round($qty * $products[$sku]['price'], 4); + $po_items[] = [ + 'item_id' => $i + 1, 'product_sku' => $sku, 'product_name' => $products[$sku]['name'], + 'quantity' => $qty, 'unit_price' => $products[$sku]['price'], 'total_price' => $total, + 'tax_rate' => 7.0, 'tax_amount' => round($total * 0.07, 4), + ]; + } + $po_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $supplier_id, $wh_dept_id, $main_wh, $po_items, $today, $po_marker, $logging) { + $poMgmt = new PurchaseOrderManager($pdo, $company_id); + $pid = $poMgmt->savePo([ + 'id' => 0, 'contact_id' => $supplier_id, 'department_id' => $wh_dept_id, + 'po_date' => $today, 'expected_date' => date('Y-m-d', strtotime('+7 days')), + 'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0, 'shipping_fee' => 0, + 'notes' => $po_marker, + ], $logging); + $poMgmt->confirmPo($pid, $logging); + return $pid; + }); + ok("Created + confirmed PO for 3 laptops and 1 ERP licence (id={$po_id})"); +} + +$pi_id = $lookup( + "SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :p AND doc_type = 'purchase_invoice' AND status <> 4 LIMIT 1", + [':c' => $company_id, ':p' => $po_id] +); +if ($pi_id) { + skip("Purchase invoice for the asset PO already exists (id={$pi_id})"); +} else { + $pi_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $po_id, $logging) { + $invMgmt = new InvoiceManager($pdo, $company_id); + $iid = $invMgmt->createFromPo($po_id, $logging); + $invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days'))); + return $iid; + }); + ok("Created + issued purchase invoice from the asset PO (id={$pi_id})"); +} + +$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id)); +if ($gl->getBySource('purchase_invoice', $pi_id)) { + skip("GL already posted for purchase invoice #{$pi_id}"); +} else { + // Same as order/api/engine/issue_invoice.php's auto-post. + $built = (new PurchaseInvoicePosting($pdo2, $company_id))->build($pi_id, null); + $pdo2->beginTransaction(); + $gl->post('purchase_invoice', $pi_id, $built['formula_id'], $built['period'], $built['lines'], ['journal_date' => $built['doc_date']]); + $pdo2->commit(); + ok("GL posted for purchase invoice #{$pi_id} (" . count($built['lines']) . " lines)"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Assets +// ───────────────────────────────────────────────────────────────────────────── +echo "\n--- Assets ---\n"; + +$sth = $pdo2->prepare("SELECT product_sku, id FROM td_invoice_item WHERE company_id = :c AND invoice_id = :i"); +$sth->execute([':c' => $company_id, ':i' => $pi_id]); +$invoice_items = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // sku => td_invoice_item.id + +$asset_defs = [ + ['key' => 'laptops', 'name' => 'Laptop fleet - Dell Latitude 5440 (3 units)', 'category' => 'IT', 'sku' => 'FA-LAPTOP', 'salvage' => 6000, 'life' => 36], + ['key' => 'erp', 'name' => 'ERP software licence', 'category' => 'SW', 'sku' => 'FA-ERP', 'salvage' => 0, 'life' => 24], + ['key' => 'truck', 'name' => 'Delivery truck - Isuzu D-Max', 'category' => 'VEH', 'acquired' => date('Y-m-15', strtotime('first day of -18 months')), 'cost' => 850000, 'salvage' => 150000, 'life' => 60], + ['key' => 'forklift','name' => 'Forklift - Toyota 8FD25', 'category' => 'VEH', 'acquired' => date('Y-m-01', strtotime('first day of -30 months')), 'cost' => 420000, 'salvage' => 20000, 'life' => 48], +]; + +$asset_ids = []; +foreach ($asset_defs as $d) { + $sth = $pdo2->prepare("SELECT id, status FROM td_asset WHERE company_id = :c AND asset_name = :n AND status <> 4 LIMIT 1"); + $sth->execute([':c' => $company_id, ':n' => $d['name']]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if ($row) { + skip("Asset '{$d['name']}' already exists (id={$row['id']})"); + $id = (int)$row['id']; + } else { + $data = [ + 'asset_name' => $d['name'], 'category_id' => $category_ids[$d['category']], + 'salvage_value' => $d['salvage'], 'useful_life_months' => $d['life'], + 'department_id' => $wh_dept_id, 'description' => 'Demo seed', + ]; + if (!empty($d['sku'])) { + // Capitalized from the purchase invoice line, as asset/manage_asset.php?invoice_id=&item_id= does. + $item_id = (int)($invoice_items[$d['sku']] ?? 0); + $line = (new AssetManager($pdo2, $company_id))->getInvoiceLine($pi_id, $item_id); + $data += [ + 'invoice_id' => $pi_id, 'invoice_item_id' => $item_id, + 'cost' => $line['remaining'], 'clearing_account_code' => $line['clearing_account_code'], + 'acquisition_date' => $line['issued_date'], 'in_service_date' => $line['issued_date'], + ]; + } else { + // Bought before the system was used: already on the books, so no clearing account. + $data += ['cost' => $d['cost'], 'clearing_account_code' => '', 'acquisition_date' => $d['acquired'], 'in_service_date' => $d['acquired']]; + } + $id = dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->save($data, $owner_user_id)); + ok("Created asset '{$d['name']}' (id={$id}), cost " . number_format((float)$data['cost'], 2) . ", in service {$data['in_service_date']}"); + } + + if ((int)($row['status'] ?? 0) === 0) { + dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->activate($id)); + ok("Activated '{$d['name']}'"); + } + $asset_ids[$d['key']] = $id; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Runs and the forklift sale +// ───────────────────────────────────────────────────────────────────────────── +echo "\n--- Depreciation / amortization runs ---\n"; + +$run_through = function (string $asset_class, string $period) use ($pdo2, $company_id, $owner_user_id) { + $label = AssetRunManager::labelFor($asset_class); + if (!(new AssetRunManager($pdo2, $company_id))->preview($asset_class, $period)) { + skip("{$label} already charged through {$period}"); + return; + } + $created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($asset_class, $period, $owner_user_id)); + $first = $created[0]['run_number']; + $last = $created[count($created) - 1]['run_number']; + ok("{$label}: " . count($created) . " run(s) {$first}" . ($first !== $last ? " .. {$last}" : '') + . ", total " . number_format(array_sum(array_column($created, 'total')), 2)); +}; + +$run_through('tangible', $last_period); + +$forklift = (new AssetManager($pdo2, $company_id))->getById($asset_ids['forklift']); +if (in_array((int)$forklift['status'], [AssetManager::STATUS_ACTIVE, AssetManager::STATUS_FULLY], true)) { + dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->dispose($asset_ids['forklift'], [ + 'disposal_date' => $today, 'sale_price' => 180000, 'proceeds_account_code' => '1000', + 'disposal_notes' => 'Sold to a used-equipment dealer (demo seed)', + ])); + ok("Sold '{$forklift['asset_name']}' today for 180,000.00 (book value " . number_format((float)$forklift['book_value'], 2) . ")"); +} else { + skip("'{$forklift['asset_name']}' already disposed of"); +} + +$run_through('tangible', $this_period); +$run_through('intangible', $this_period); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. GL posting (Batch GL Entries equivalent) +// ───────────────────────────────────────────────────────────────────────────── +echo "\n--- GL posting ---\n"; + +$post_asset_gl = function (string $source_type, int $id) use ($pdo1, $pdo2, $company_id) { + $gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id)); + if ($gl->getBySource($source_type, $id)) return null; + + $built = (new AssetPosting($pdo2, $company_id))->build($source_type, $id); + $pdo2->beginTransaction(); + try { + $gl->post($source_type, $id, 0, $built['period'], $built['lines'], [ + 'journal_date' => $built['doc_date'], 'reference' => $built['reference'], 'description' => $built['description'], + ]); + $pdo2->commit(); + } catch (Throwable $e) { + if ($pdo2->inTransaction()) $pdo2->rollBack(); + throw $e; + } + return $built; +}; + +$queue = []; +$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status IN (1, 2, 3) AND clearing_account_code <> '' ORDER BY id"); +$sth->execute([':c' => $company_id]); +foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_capitalization', (int)$id]; + +$sth = $pdo2->prepare("SELECT id, asset_class FROM td_asset_run WHERE company_id = :c AND status = 1 AND period < :p ORDER BY period, id"); +$sth->execute([':c' => $company_id, ':p' => $this_period]); +foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $r) $queue[] = [AssetRunManager::sourceTypeFor($r['asset_class']), (int)$r['id']]; + +$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status = 3 ORDER BY id"); +$sth->execute([':c' => $company_id]); +foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_disposal', (int)$id]; + +$posted = 0; +foreach ($queue as [$source_type, $id]) { + $built = $post_asset_gl($source_type, $id); + if ($built) { + $posted++; + ok("Posted {$built['reference']} — {$built['description']}"); + } +} +if (!$posted) skip("Asset GL entries already posted"); +info("This month's runs are left unposted — post them from Accounting > Batch GL Entries."); + +echo "\n\033[1m=== Done ===\033[0m\n\n"; diff --git a/demo_seed_more_orders.php b/demo_seed_more_orders.php index bdfa7f5..c9084dc 100644 --- a/demo_seed_more_orders.php +++ b/demo_seed_more_orders.php @@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array { $tax_amount = round($total_price * $tax_rate / 100, 4); return [ 'product_sku' => $sku, - 'product_name' => $sku, + 'product_name' => $p['product_name'], 'quantity' => $qty, 'unit_price' => $unit_price, 'total_price' => $total_price, @@ -69,11 +69,16 @@ $sth->execute([':c' => $company_id]); $sales_dept_id = (int)$sth->fetchColumn(); if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); } -$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c"); +$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c"); $sth->execute([':c' => $company_id]); $products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE); -$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id"); +// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id. +$sth = $pdo2->prepare( + "SELECT c.id, c.contact_name FROM md_contact c + JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type + WHERE c.company_id = :c AND t.contact_type = 'Customer' ORDER BY c.id" +); $sth->execute([':c' => $company_id]); $customers = $sth->fetchAll(PDO::FETCH_ASSOC); $customer_ids = array_column($customers, 'id'); diff --git a/demo_seed_transactions.php b/demo_seed_transactions.php index 8839218..51a180d 100644 --- a/demo_seed_transactions.php +++ b/demo_seed_transactions.php @@ -5,18 +5,16 @@ * * Extends the base demo data (demo_seed.php: company, warehouses, products, * contacts, opening stock) with transactional data covering the rest of the - * app's features: + * app's features (restock / dispatch / transfer movements are seeded by demo_seed.php): * - * 1. Additional stock-in replenishment (restock events) - * 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna) - * 3. Chart of accounts, departments, GL posting formulas - * 4. Sales cycle: quotation -> sales order -> invoice -> GL post - * 5. AR: receipt billing -> receipt -> GL post - * 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post - * 7. AP: payment billing -> payment -> GL post - * 8. Supplier return (confirmed, restocks reversed) - * 9. Customer return (draft only — see note below) - * 10. Barcode labels for a handful of products + * 4. Chart of accounts, departments, GL posting formulas, product account mapping + * 5. Sales cycle: quotation -> sales order -> invoice -> GL post + * 6. AR: receipt billing -> receipt -> GL post + * 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post + * 8. AP: payment billing -> payment -> GL post + * 9. Supplier return (confirmed, restocks reversed) + * 10. Customer return (draft only — see note below) + * 11. Barcode labels for a handful of products * * Every write goes through the same Manager classes + engine-file patterns * the app itself uses (dbTransaction wrapping, GlManager posting exactly as @@ -46,6 +44,7 @@ require_once __DIR__ . '/app/dbconn.php'; require_once __DIR__ . '/app/assets/utils/db_helpers.php'; require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php'; require_once __DIR__ . '/app/assets/utils/classes/StockManager.php'; +require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php'; require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php'; require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php'; require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php'; @@ -89,24 +88,29 @@ $sth->execute(); $owner_user_id = (int)($sth->fetchColumn() ?: 0); if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n"); +// Resolve by name: ids depend on what else exists in the database. $sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id"); $sth->execute([':c' => $company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name -if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n"); -$wh_ids = array_keys($warehouses); -$main_wh = $wh_ids[0]; // Main Warehouse -$bangna_wh = $wh_ids[1]; // Bangna Distribution Center +$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0); +$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0); +if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n"); -$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id"); +// md_contact.contact_type is an md_contact_type id, so match on the type name. +$sth = $pdo2->prepare( + "SELECT c.id, c.contact_name, t.contact_type AS type_name + FROM md_contact c + JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type + WHERE c.company_id = :c + ORDER BY c.id" +); $sth->execute([':c' => $company_id]); $contacts = $sth->fetchAll(PDO::FETCH_ASSOC); -if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n"); -$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id'); -$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id'); -$customer_ids = array_values($customer_ids); -$supplier_ids = array_values($supplier_ids); +$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id')); +$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id')); +if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n"); -$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id"); +$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id"); $sth->execute([':c' => $company_id]); $products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE); if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n"); @@ -114,156 +118,6 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p $logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions']; $whMgmt = new WarehouseManager($pdo2, $company_id); -$stockMgmt = new StockManager($pdo2, $company_id); - -/** Find the next unused simple-location bin label "A-N" for a warehouse. */ -function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string { - $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' => $warehouse_id]); - $bin = $sth->fetchColumn(); - if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}."); - return $bin; -} - -// ───────────────────────────────────────────────────────────────────────────── -// 1. Additional stock-in replenishment (restock events) -// ───────────────────────────────────────────────────────────────────────────── -echo "--- Restock (additional stock-in) ---\n"; - -$restock_defs = [ - ['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0], - ['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1], - ['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2], -]; - -foreach ($restock_defs as $r) { - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}` - WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'" - ); - $sth->execute([':c' => $company_id, ':sku' => $r['sku']]); - if ((int)$sth->fetchColumn() > 0) { - skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists"); - continue; - } - - $bin = nextFreeBin($pdo2, $company_id, $r['warehouse']); - $supplier_id = $supplier_ids[$r['supplier_idx']]; - $uuid = bin2hex(random_bytes(16)); - $p = $products[$r['sku']]; - - dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) { - $stock_id = $stockMgmt->saveStockIn([ - 'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'], - 'quantity' => $r['qty'], 'price' => $p['cost_price'], - 'zone' => $bin, 'aisle' => $bin, 'bin' => $bin, - 'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)', - ], $logging, $uuid); - $stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt); - }); - ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}"); -} - -// ───────────────────────────────────────────────────────────────────────────── -// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out -// ───────────────────────────────────────────────────────────────────────────── -echo "\n--- Stock-out (direct dispatch) ---\n"; - -$dispatch_defs = [ - ['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0], - ['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1], -]; - -foreach ($dispatch_defs as $d) { - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}` - WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'" - ); - $sth->execute([':c' => $company_id, ':sku' => $d['sku']]); - if ((int)$sth->fetchColumn() > 0) { - skip("Dispatch for {$d['sku']} already exists"); - continue; - } - - $bin = nextFreeBin($pdo2, $company_id, $d['warehouse']); - $customer_id = $customer_ids[$d['customer_idx']]; - $p = $products[$d['sku']]; - - dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) { - // Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins) - $in_uuid = bin2hex(random_bytes(16)); - $in_id = $stockMgmt->saveStockIn([ - 'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'], - 'quantity' => $d['qty'], 'price' => $p['cost_price'], - 'zone' => $bin, 'aisle' => $bin, 'bin' => $bin, - 'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)', - ], $logging, $in_uuid); - $stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt); - - // Dispatch it straight out (saveStockOut takes the whole bin) - $out_uuid = bin2hex(random_bytes(16)); - $out_id = $stockMgmt->saveStockOut([ - 'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'], - 'zone' => $bin, 'aisle' => $bin, 'bin' => $bin, - 'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)', - ], $logging, $out_uuid); - $stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt); - }); - ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}"); -} - -// ───────────────────────────────────────────────────────────────────────────── -// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center -// ───────────────────────────────────────────────────────────────────────────── -echo "\n--- Stock transfer (Main -> Bangna) ---\n"; - -$transfer_defs = [ - ['sku' => 'BV-001', 'qty' => 30], - ['sku' => 'PK-002', 'qty' => 12], -]; - -foreach ($transfer_defs as $t) { - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM `td_stock_{$main_wh}` - WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'" - ); - $sth->execute([':c' => $company_id, ':sku' => $t['sku']]); - if ((int)$sth->fetchColumn() > 0) { - skip("Transfer for {$t['sku']} already exists"); - continue; - } - - // Bin allocation is independent of the transaction below — resolve both up front. - $from_bin = nextFreeBin($pdo2, $company_id, $main_wh); - $to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh); - $p = $products[$t['sku']]; - - dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) { - // Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins) - $in_uuid = bin2hex(random_bytes(16)); - $in_id = $stockMgmt->saveStockIn([ - 'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'], - 'quantity' => $t['qty'], 'price' => $p['cost_price'], - 'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin, - 'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)', - ], $logging, $in_uuid); - $stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt); - - $tr_uuid = bin2hex(random_bytes(16)); - $out_id = $stockMgmt->saveStockTransfer([ - 'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh, - 'product_sku' => $t['sku'], - 'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin, - 'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin, - 'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)', - ], $logging, $tr_uuid); - $stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt); - }); - ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}"); -} // ───────────────────────────────────────────────────────────────────────────── // 4. Chart of accounts + departments + GL posting formulas @@ -379,6 +233,25 @@ foreach ($formula_defs as $doc_type => $def) { $formula_ids[$doc_type] = $id; } +echo "\n--- Product account mapping ---\n"; + +// The sales and purchase formulas split 'total' per product, so Batch GL Entries +// refuses to post until every product has sales/purchase accounts +// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php). +$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id"); +$sth->execute([':c' => $company_id]); +foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) { + if ($prod['sales_account_code'] && $prod['purchase_account_code']) { + skip("Product {$prod['sku']} already mapped"); + continue; + } + $sales_code = $prod['sales_account_code'] ?: '4000'; + $purchase_code = $prod['purchase_account_code'] ?: '5000'; + dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id)) + ->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging)); + ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}"); +} + /** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */ function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array { require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php"; @@ -409,7 +282,7 @@ function buildLineItem(array $products, string $sku, float $qty): array { $tax_amount = round($total_price * $tax_rate / 100, 4); return [ 'product_sku' => $sku, - 'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness + 'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it 'quantity' => $qty, 'unit_price' => $unit_price, 'total_price' => $total_price,