Compare commits

30 Commits
Author SHA1 Message Date
Thanakorn 80c7eab0d2 Merge fix/manual-journal 2026-09-19 06:28:11 +07:00
Thanakorn 033846dece Fix stock-out boot, transfer 500, Clear buttons and uncaught engine errors
Return JSON from any uncaught engine exception, stop the empty stock-out warehouse list aborting page boot, reject non-transfer rows in the transfer lookup, define the missing reset_input helper, and remove a stale unreferenced copy of confirm_order.php.
2026-09-18 20:21:34 +07:00
Thanakorn c14822add6 Merge fix/manual-journal 2026-09-18 16:33:35 +07:00
Thanakorn c60b705d98 Fix GL journal save, edit, detail view and list filters
Send journal lines, gl_id and list filters inside the ajax data payload where ajax_request reads them, select period when replacing a manual journal, and show ledger amounts to two decimals.
2026-09-18 16:19:27 +07:00
Thanakorn d8363f6ca7 Merge fix/feedback-16-09 2026-09-17 09:00:37 +07:00
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00
Thanakorn f14c850c70 Merge fix/accounting-feedback 2026-09-15 16:13:55 +07:00
Thanakorn c915379e2e Fix item counts, PR/QT conversions, validation and department loss 2026-09-15 16:11:47 +07:00
Thanakorn 78d69d81df Merge fix/stock-transfer-seed 2026-09-15 13:13:18 +07:00
Thanakorn 693c72f9ea Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts and use product names and supplier batches.
2026-09-15 13:09:19 +07:00
Thanakorn 9512d440dd Merge fix/switch-branch 2026-09-14 17:19:02 +07:00
Thanakorn 45331948c1 Use absolute URLs in invitation emails 2026-09-14 17:18:42 +07:00
Thanakorn ba73456185 Send the chosen company when switching branch 2026-09-14 17:17:40 +07:00
Thanakorn 501c70050f Merge fix/untrack-node-logs 2026-09-14 17:06:16 +07:00
Thanakorn f6909b08eb Stop tracking PM2 log files 2026-09-14 17:05:57 +07:00
Thanakorn 5846c8b282 Merge fix/app-registry-default 2026-09-14 16:58:00 +07:00
Thanakorn 6a271c1f7d Default the app registry when config.php does not define it 2026-09-14 16:57:36 +07:00
Thanakorn 2ef2f32107 Merge fix/retrieve-bin-endpoint 2026-09-14 16:29:56 +07:00
Thanakorn 1f72633cb6 Install libpng, libjpeg and freetype for the gd extension 2026-09-14 16:29:38 +07:00
Thanakorn 4efab9f7c9 Rename retrieve_rack.php to retrieve_bin.php 2026-09-14 16:27:53 +07:00
Thanakorn 44c66c49a5 Merge feature/otp-off-by-default 2026-09-14 15:38:53 +07:00
Thanakorn 6b3a590aa9 Make email OTP login off by default 2026-09-14 15:38:36 +07:00
Thanakorn 21148bf50c Merge feature/onboarding-optional-smtp 2026-09-14 15:27:46 +07:00
Thanakorn cf8106771b Make onboarding SMTP optional when OTP is off 2026-09-14 15:27:01 +07:00
Thanakorn d7203583b7 Merge feature/otp-login-toggle 2026-09-14 15:11:45 +07:00
Thanakorn 9afcf072b0 Add OTP_REQUIRED switch for email OTP login 2026-09-14 15:03:16 +07:00
Thanakorn 2f290ddb26 Merge fix/api-json-notices 2026-09-14 13:33:59 +07:00
Thanakorn 5d021d7683 Accept uppercase channel names in onboarding and company settings 2026-09-14 13:31:26 +07:00
Thanakorn 5ee0c8d41b Keep PHP notices out of login API JSON responses 2026-09-14 12:46:13 +07:00
Thanakorn c3113bc70d Fix login redirect and hide PHP errors on pages 2026-09-14 12:46:13 +07:00
383 changed files with 6544 additions and 10721 deletions
+6
View File
@@ -13,5 +13,11 @@ EMIT_SECRET=
SMTP_USERNAME=
SMTP_PASSWORD=
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
# and that needs working SMTP. While off, sign-in is password only (logged as
# OTP_BYPASSED, shown on the login page and top bar).
# Applied to app/config.php by the php container on every start.
OTP_REQUIRED=false
# Port to expose the web app on (default 80)
HTTP_PORT=80
+3 -3
View File
@@ -6,6 +6,9 @@ app/uploads
node_modules/
nodejs/.env
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
nodejs/logs/*.log
# Docker deploy secrets
/.env
@@ -16,6 +19,3 @@ lib/zxcvbn-php-master/vendor/sebastian/
notes/
docs/
SESSION.php
# SDLC delivery zip is packaged by hand, outside the repo
sdlc-delivery.zip
+1 -1
View File
@@ -509,7 +509,7 @@
var extra_fields = h.source_type === 'manual'
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
+1 -1
View File
@@ -222,7 +222,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+1 -1
View File
@@ -89,7 +89,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+5 -4
View File
@@ -157,8 +157,10 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: from,
open_to: to,
// Inside `data`: as top-level options these were ignored, and the save
// only worked because autoPrepare happens to sweep the two inputs, whose
// ids match the field names.
data: { open_from: from, open_to: to },
onSuccess: function() {
render_display(from, to);
}
@@ -173,8 +175,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: '',
open_to: '',
data: { open_from: '', open_to: '' },
onSuccess: function() {
render_display('', '');
}
+108 -4
View File
@@ -581,9 +581,19 @@ function load_formula_options(select_id, document_type, selected_id, onLoaded) {
});
}
// Line tax rate; derived from tax_amount / total_price when only the amount was stored
function line_tax_rate(item) {
var rate = parseFloat(item.tax_rate) || 0;
var amount = parseFloat(item.tax_amount) || 0;
var total = parseFloat(item.total_price) || 0;
if (rate === 0 && amount > 0 && total > 0) rate = round_dp(amount / total * 100, 2);
return rate;
}
// Returns the request promise so callers can await the options before selecting a value
function load_departments(select_id, selected_id) {
var ctx = document.getElementById('session-context');
ajax_request({
return ajax_request({
url: server_url + 'accounting/api/engine/department.php',
action: 'get',
queueLock: false,
@@ -762,6 +772,42 @@ function ajax_request(options) {
// Override options.data with the full FormData object
options.data = options.formData;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- START MODIFIED $.AJAX BLOCK ---
@@ -995,15 +1041,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;
@@ -1019,6 +1074,36 @@ function to_iso_date(dateStr) {
}
/**
* Clear every field inside a form or form-like container — the "Clear" buttons
* on the master-data pages. It was called by five pages but never defined, so
* each click threw a ReferenceError, and where the container is a <div> rather
* than a <form> (Chart of Accounts, Departments) the button did nothing at all.
* Disabled and hidden inputs are left alone: those carry the record id and
* locked values, not user input.
*/
function reset_input(selector) {
var $scope = $(selector);
if (!$scope.length) return;
$scope.find('input, textarea, select').each(function () {
if (this.disabled || this.type === 'hidden' || this.type === 'button' || this.type === 'submit') return;
if (this._flatpickr) {
this._flatpickr.clear();
} else if (this.type === 'checkbox' || this.type === 'radio') {
this.checked = this.defaultChecked;
} else if (this.tagName === 'SELECT') {
this.selectedIndex = 0;
} else {
this.value = '';
}
$(this).removeAttr('secondary').removeClass('is-invalid is-valid');
});
}
function round_dp(value, places) {
var factor = Math.pow(10, places);
return Math.round((Number(value) + Number.EPSILON) * factor) / factor;
@@ -1054,6 +1139,25 @@ function expand_exponential_number(value) {
return sign + digits.slice(0, point) + '.' + digits.slice(point);
}
/**
* Format a stock quantity without hiding real data.
*
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
* data" — the stock popups and list pages all showed an empty-looking QTY for
* a receipt that had in fact been made. Show 2 dp normally, and the stored
* 4 dp whenever rounding to 2 would lose something.
*/
function format_quantity(value) {
var n = Number(value);
if (isNaN(n)) return '--';
return (round_dp(n, 2) !== round_dp(n, 4))
? format_number(n, 4)
: format_number(n, 2);
}
function format_number(value, decimal) {
var n = Number(value);
if (isNaN(n)) return '--';
@@ -1165,7 +1269,7 @@ function show_stock_rows(source, source_id, label) {
<td>${escape_html(r.warehouse_name)}</td>
<td><small>${escape_html(location)}</small></td>
<td><small>${escape_html(r.lot_number || '—')}</small></td>
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td>
<td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
<td>${status_badge}</td>
<td><small>${format_date(r.date)}</small></td>
</tr>`;
+22
View File
@@ -0,0 +1,22 @@
<?php
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
//
// config.php may define its own $app_registry; this file only fills it in when
// it is missing or empty — which is every Docker-generated config.php written
// before the setting was documented. Without it the Add User dialog breaks
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// ('all' is implied and never listed here).
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
}
@@ -99,7 +99,7 @@ class CompanyProfileManager
public function saveProfile(array $data, string $company_logo, string $company_seal): void
{
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$sth = $this->pdo->prepare(
"UPDATE company_list SET
+55 -2
View File
@@ -403,12 +403,47 @@ class InvoiceManager {
* @param array $logging Audit entry.
* @throws Exception If invoice not found or not in draft status.
*/
/**
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
* that is not a real calendar date.
*
* The date pickers display d/m/Y, and a page that forgets to convert before
* posting sends that text straight through to a MySQL DATE column, where it
* fails as a PDOException and surfaces to the user as the opaque
* "Database error, please try again." Accepting both spellings here keeps
* the failure mode a named, actionable message instead.
*
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
* @param string $label Field name used in the error message.
* @return string|null ISO date, or null when nothing was supplied.
* @throws Exception When the value is not a valid date.
*/
private function normaliseDate(string $value, string $label): ?string
{
$value = trim($value);
if ($value === '') return null;
// Strip a time part, if the caller passed a datetime.
$value = explode(' ', $value)[0];
foreach (['Y-m-d', 'd/m/Y'] as $format) {
$parsed = DateTime::createFromFormat('!' . $format, $value);
// createFromFormat() accepts overflowing values such as 32/01/2026
// and rolls them over, so compare the round-trip to reject those.
if ($parsed && $parsed->format($format) === $value) {
return $parsed->format('Y-m-d');
}
}
throw new Exception("{$label} is not a valid date.");
}
public function saveInvoice(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT status, doc_type, issued_date, `log` FROM td_invoice
"SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -428,8 +463,21 @@ class InvoiceManager {
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
// Absent key means "not being edited" — keep what is stored rather than
// clearing it, so a caller that posts only tax_adjustment cannot wipe
// the agreed payment term.
$due_date = array_key_exists('due_date', $data)
? $this->normaliseDate((string)$data['due_date'], 'Due date')
: ($row['due_date'] ?: null);
$issued_date = $row['issued_date'] ?: null;
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$params = [
':due_date' => $data['due_date'] ?: null,
':due_date' => $due_date,
':notes' => $data['notes'] ?? '',
':formula_id' => $formula_id,
':log' => json_encode($log),
@@ -525,6 +573,11 @@ class InvoiceManager {
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
throw new Exception("Due date is required before issuing this document.");
}
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
if ($due_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
$log = json_decode($row['log'] ?? '[]', true) ?: [];
+11 -3
View File
@@ -260,7 +260,10 @@ class OrderManager {
{
$sth = $this->pdo->prepare(
"SELECT o.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_order_item i
WHERE i.company_id = o.company_id
AND i.order_id = o.id) AS item_count
FROM td_order o
LEFT JOIN md_contact c
ON c.company_id = o.company_id
@@ -493,7 +496,7 @@ class OrderManager {
// Fetch existing row to check status and load log
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_order
"SELECT status, department_id, `log` FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -502,6 +505,11 @@ class OrderManager {
if (!$row) {
throw new Exception("Order not found.");
}
// Pages without a department field (Revenue SO) must not wipe the stored one
$department_id = array_key_exists('department_id', $data)
? (int)$data['department_id']
: (int)$row['department_id'];
$cur_status = (int)$row['status'];
if ($cur_status !== 0 && $cur_status !== -2) {
throw new Exception("Only draft or pending orders can be edited.");
@@ -541,7 +549,7 @@ class OrderManager {
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':department_id' => $department_id,
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':subtotal' => $subtotal,
':discount' => $discount,
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/WarehouseManager.php';
require_once __DIR__ . '/StockManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -194,7 +195,10 @@ class PurchaseOrderManager {
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_purchase_order_item i
WHERE i.company_id = p.company_id
AND i.order_id = p.id) AS item_count
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
@@ -572,7 +576,16 @@ class PurchaseOrderManager {
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0);
if ($quantity <= 0) continue;
// A blank line is a line the user chose not to receive — skip it.
if ($quantity == 0) continue;
// Anything positive has to survive the decimal(18,4) columns it is
// about to be written to. Without this, 0.0000001 was accepted, was
// stored as 0.0000, produced a stock movement of nothing, and still
// advanced received_qty enough to leave the PO stuck on "Partial".
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
@@ -597,6 +610,16 @@ class PurchaseOrderManager {
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// Expiry dates live on md_lot, keyed by lot number, so an expiry
// entered without one is silently dropped and the received stock
// shows no expiry at all. Say so instead of discarding it.
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
&& trim((string)($recv['lot_number'] ?? '')) === '') {
throw new Exception(
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
);
}
// Simple location mode: zone and aisle must mirror the bin value
// (same convention as manage_stock_in.php).
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
@@ -94,7 +94,10 @@ class PurchaseRequestManager
WHERE p.company_id = r.company_id
AND p.source = 'purchase_request'
AND p.source_id = r.id
AND p.status != -1) AS linked_po_count
AND p.status != -1) AS linked_po_count,
(SELECT COUNT(*) FROM td_purchase_request_item i
WHERE i.company_id = r.company_id
AND i.request_id = r.id) AS item_count
FROM td_purchase_request r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
@@ -166,6 +169,12 @@ class PurchaseRequestManager
if (empty($items)) throw new Exception('At least one item is required.');
$request_date = (string)($data['request_date'] ?? '');
$required_date = (string)($data['required_date'] ?? '');
if ($request_date !== '' && $required_date !== '' && $required_date < $request_date) {
throw new Exception('Required date cannot be earlier than the request date.');
}
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
if ($id === 0) {
+22 -1
View File
@@ -89,7 +89,10 @@ class QuotationManager
public function getList(): array
{
$sth = $this->pdo->prepare(
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_quotation_item i
WHERE i.company_id = q.company_id
AND i.quotation_id = q.id) AS item_count
FROM td_quotation q
LEFT JOIN md_contact c
ON c.id = q.contact_id AND c.company_id = q.company_id
@@ -173,6 +176,24 @@ class QuotationManager
$items = $data['items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$quotation_date = (string)($data['quotation_date'] ?? '');
$valid_until = (string)($data['valid_until'] ?? '');
if ((int)($data['contact_id'] ?? 0) <= 0) {
throw new Exception('Contact is required.');
}
if ($quotation_date === '') {
throw new Exception('Quotation date is required.');
}
if ($valid_until !== '' && $valid_until < $quotation_date) {
throw new Exception('Valid until cannot be earlier than the quotation date.');
}
if ((int)($data['department_id'] ?? 0) <= 0) {
throw new Exception('Department is required.');
}
if (empty($items)) {
throw new Exception('At least one line item is required.');
}
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
if ($id === 0) {
+4 -1
View File
@@ -169,7 +169,10 @@ class ReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
o.order_number
o.order_number,
(SELECT COUNT(*) FROM td_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
+133 -25
View File
@@ -26,6 +26,18 @@ require_once __DIR__ . '/../notify_node.php';
*/
class StockManager {
/**
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
* and produced a movement of nothing that still left the source document
* "partially received".
*/
public const QTY_SCALE = 4;
/** Smallest quantity the schema can represent — 0.0001. */
public const QTY_MIN = 0.0001;
private PDO $pdo;
private int $company_id;
@@ -56,6 +68,39 @@ class StockManager {
return 'td_stock_' . $warehouse_id;
}
/**
* Round a quantity to the stored scale and reject values that cannot be
* represented.
*
* A positive input that rounds to zero is a mistake worth naming — the
* caller asked to move some stock and would otherwise get a zero-quantity
* movement that looks successful and reports as "0.00" everywhere.
*
* @param mixed $value Raw client input.
* @param string $label Field name used in the error message.
* @return float Quantity rounded to QTY_SCALE.
* @throws Exception When the value is not a usable quantity.
*/
public static function normaliseQuantity($value, string $label = 'Quantity'): float
{
$raw = (float)$value;
if ($raw <= 0) {
throw new Exception("{$label} must be greater than zero.");
}
$rounded = round($raw, self::QTY_SCALE);
if ($rounded < self::QTY_MIN) {
throw new Exception(
"{$label} of {$raw} is smaller than the minimum the system records (" .
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
);
}
return $rounded;
}
private function stockReferenceSql(): string
{
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
@@ -72,35 +117,93 @@ class StockManager {
* The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0).
*
* @param int $warehouse_id The md_warehouse.id to query.
* @param int $warehouse_id The md_warehouse.id to query, or 0 for every
* warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
* @return array Stock rows ordered by date DESC, each with 'quantity',
* 'product_name', 'warehouse_id' and 'warehouse_name'.
*/
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)';
// warehouse_id 0 = every warehouse. Stock lives in one table per
// warehouse, so a single-warehouse list hides the rest of a receipt
// that was split across warehouses — a 4-line PO received into two of
// them looked like only 3 lines had been received.
$warehouses = $warehouse_id > 0
? [$warehouse_id]
: $this->warehouseIdsWithStockTable();
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
// Quantity is NOT rounded for display here: rounding to 2 dp reports a
// small-but-real quantity as "0.00", which reads as missing data.
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$rows = [];
foreach ($warehouses as $wh_id) {
$table = $this->stockTableNameFromWarehouseId($wh_id);
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
b.product_name, b.uom,
w.warehouse_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
LEFT JOIN md_warehouse w
ON w.company_id = a.company_id
AND w.id = :warehouse_id
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $wh_id,
':type' => $type,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// The row's own warehouse, so the list can link each Action
// back to the right td_stock_<id> table when showing them all.
$row['warehouse_id'] = $wh_id;
$rows[] = $row;
}
}
// Re-sort across warehouses — each table was only ordered internally.
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
return $rows;
}
/**
* Warehouse ids of this company that actually have a stock table.
*
* td_stock_<id> tables are created lazily on first use, so a warehouse with
* no movements yet has none and must be skipped rather than queried.
*
* @return int[]
*/
private function warehouseIdsWithStockTable(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
"SELECT w.id
FROM md_warehouse w
JOIN information_schema.tables t
ON t.table_schema = DATABASE()
AND t.table_name = CONCAT('td_stock_', w.id)
WHERE w.company_id = :company_id
ORDER BY w.id"
);
$sth->execute([
':company_id' => $this->company_id,
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$sth->execute([':company_id' => $this->company_id]);
return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
}
/**
@@ -204,7 +307,10 @@ class StockManager {
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$output = $sth->fetch(PDO::FETCH_ASSOC);
if (!$output) return false;
// Only a transfer's outbound row names a destination. Any other row
// (a stock-in or stock-out reached through a stale or edited link) has
// no ref_warehouse, and resolving it threw "Invalid warehouse id."
if (!$output || $output['type'] !== 'transfer' || (int)$output['ref_warehouse'] <= 0) return false;
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
@@ -257,8 +363,8 @@ class StockManager {
$warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0);
if ($id === 0 && $quantity <= 0) {
throw new Exception("Quantity must be greater than zero.");
if ($id === 0) {
$quantity = self::normaliseQuantity($quantity);
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
@@ -439,8 +545,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 +718,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;
@@ -154,7 +154,10 @@ class SupplierReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
p.po_number
p.po_number,
(SELECT COUNT(*) FROM td_supplier_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_supplier_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
@@ -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
+5 -1
View File
@@ -81,7 +81,11 @@ class GlManager
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"SELECT id, current_version, formula_id, history
// `period` is read below as $old_period to reverse the old ETL
// totals. It was missing from this list, so it was always null and
// upsertEtl(string $period) threw a TypeError — every edit of a
// manual journal ended in HTTP 500.
"SELECT id, current_version, formula_id, history, period
FROM td_gl
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
FOR UPDATE"
@@ -75,8 +75,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
@@ -142,8 +142,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
+28
View File
@@ -7,6 +7,34 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json; charset=utf-8');
// Last-resort handler for exceptions an engine does not catch itself. Many
// engines call a manager with no try/catch, so any exception — including the
// managers' own deliberate validation messages — used to end as a PHP fatal
// with an empty 500 body, which the browser could only report as "Server
// error occurred." This mirrors the convention the catching engines already
// use: a manager's Exception carries a user-facing message (400); a database
// or engine fault stays generic (500) and goes to the server log.
set_exception_handler(function (Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
if ($e instanceof PDOException) {
error_log('Uncaught PDOException: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Database error, please try again.';
} elseif ($e instanceof Exception) {
http_response_code(400);
$message = $e->getMessage();
} else {
// Error / TypeError: a programming fault, not something to show users.
error_log('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Server error occurred.';
}
echo json_encode(['success' => 0, 'message' => $message]);
});
require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
require_once __DIR__."/db_helpers.php";
+36
View File
@@ -0,0 +1,36 @@
<?php
// app/assets/utils/otp_policy.php
//
// Email OTP login policy, set by OTP_REQUIRED in config.php.
//
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
// exactly the boolean true. A missing constant (any config.php written before
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
// password only and no SMTP is needed to log in.
//
// While it is off, every sign-in that skips the OTP because of it is logged as
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
// password-only sign-in must never be invisible to whoever is using it.
//
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
// are a separate flow that stays on regardless.
if (!function_exists('otp_required')) {
function otp_required(): bool {
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
}
}
if (!function_exists('otp_log_bypass')) {
// There is no auth log table in this app, so bypasses go to the PHP error
// log (the container's Apache log) under a fixed, greppable tag.
function otp_log_bypass($user_id, string $where): void {
error_log(sprintf(
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
(int)$user_id,
$_SERVER['REMOTE_ADDR'] ?? '-',
$where
));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
// Applies the configured application timezone to PHP, and exposes the matching
// UTC offset so the database session can be pinned to the same zone.
//
// config.php has always defined $time_zone ("Asia/Bangkok"), but nothing ever
// called date_default_timezone_set() with it. PHP therefore ran on its ini
// default (UTC on this stack) while MySQL NOW() ran on the database server's
// zone (Bangkok). Every timestamp written from PHP — stock movement `date`
// above all — was stored 7 hours behind the real wall clock, so a stock-in
// created at 14:02 was listed as 07:02.
//
// Loaded from dbconn.php (covers every API engine, which is where writes
// happen) and from include_header.php (covers the rendered pages).
if (!defined('APP_TIMEZONE')) {
$app_tz = $GLOBALS['time_zone'] ?? 'Asia/Bangkok';
// An unknown identifier would leave PHP on UTC and silently reintroduce the
// skew, so fall back to the documented project zone instead.
try {
$tz = new DateTimeZone($app_tz);
} catch (Exception $e) {
$app_tz = 'Asia/Bangkok';
$tz = new DateTimeZone($app_tz);
}
date_default_timezone_set($app_tz);
define('APP_TIMEZONE', $app_tz);
// "+07:00" — the form MySQL accepts without its named-timezone tables
// having been loaded, which is the usual case on a stock install.
$offset_seconds = $tz->getOffset(new DateTime('now', $tz));
define('APP_TIMEZONE_OFFSET', sprintf(
'%s%02d:%02d',
$offset_seconds < 0 ? '-' : '+',
intdiv(abs($offset_seconds), 3600),
intdiv(abs($offset_seconds) % 3600, 60)
));
}
+18
View File
@@ -38,6 +38,24 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
// anything else, the constant being absent included, leaves sign-in password
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
// on only with working SMTP. Password-reset OTPs are not affected.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', false);
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
// must match the user.app_access enum ('wms', 'accounting'). If this is left
// out, assets/utils/app_registry.php supplies the same default.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
// enforce daily/weekly action limits and which features lock once exceeded.
+23 -1
View File
@@ -1,5 +1,9 @@
<?php
// Apply the configured application timezone before anything formats or stores a
// date. config.php (loaded by the caller) supplies $time_zone.
require_once __DIR__ . '/assets/utils/timezone.php';
// db connection
/** overide native PDO function */
class database extends PDO {
@@ -27,6 +31,11 @@ class db_statement extends PDOStatement {
$this->pdo = $pdo;
}
// PDOStatement::execute() is declared ?array $params = null : bool. This
// override deliberately accepts a looser signature so callers may pass
// positional arguments (see func_get_args() below), so the tightened return
// type is opted out of rather than the call sites being changed.
#[\ReturnTypeWillChange]
public function execute($args = null) {
// Perform logging here. PDO object is accessible
// from $this->pdo.
@@ -92,4 +101,17 @@ $pdo1->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//..................... PDO2 .....................//
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Pin both connections to the application timezone, so MySQL NOW() and PHP
// date() agree no matter how the database server itself is configured. Queries
// mix the two freely (rows written with NOW(), others with date()), and a
// mismatch shows up as timestamps hours away from the real clock.
foreach ([$pdo1, $pdo2] as $pdo_tz) {
try {
$pdo_tz->exec("SET time_zone = '" . APP_TIMEZONE_OFFSET . "'");
} catch (PDOException $e) {
// A server that refuses the offset keeps its own zone — no worse than
// before this call existed, and not a reason to fail the request.
}
}
@@ -15,10 +15,6 @@ if (!$request_id) {
$answer['message'] = 'Purchase request ID is required.';
exit(json_encode($answer));
}
if (!$contact_id) {
$answer['message'] = 'Supplier (contact_id) is required for the PO.';
exit(json_encode($answer));
}
$prm = new PurchaseRequestManager($pdo2, $company_id);
@@ -29,6 +25,15 @@ if (!$pr || (int)$pr['status'] !== 2) {
exit(json_encode($answer));
}
// Default to the PR's preferred supplier
if (!$contact_id) {
$contact_id = (int)($pr['contact_id'] ?? 0);
}
if (!$contact_id) {
$answer['message'] = 'Set a Preferred Supplier on this purchase request before converting it to a PO.';
exit(json_encode($answer));
}
$pr_items = $pr['items'];
if (empty($pr_items)) {
$answer['message'] = 'Purchase request has no items.';
@@ -92,6 +97,7 @@ foreach ($convert_items as $ci) {
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => (float)($pi['tax_rate'] ?? 0),
'received_qty' => 0,
'stock_in_id' => 0,
];
+1 -1
View File
@@ -230,7 +230,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
+5 -5
View File
@@ -194,7 +194,7 @@
<td><input type="number" class="form-control form-control-sm item_qty" value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()"></td>
<td><input type="number" class="form-control form-control-sm item_price" value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()"></td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate" value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="number" class="form-control form-control-sm item_tax_rate" value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
@@ -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);
@@ -371,8 +371,8 @@
recalc_totals();
});
$(function() {
load_departments('department_id');
$(async function() {
await Promise.resolve(load_departments('department_id')).catch(function() {});
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
if (po_id) {
+5 -4
View File
@@ -253,8 +253,8 @@
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
$('#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);
@@ -370,8 +370,9 @@
recalc_totals();
});
$(function() {
load_departments('department_id');
$(async function() {
// Options must exist before retrieve_request() selects the saved department
await Promise.resolve(load_departments('department_id')).catch(function() {});
flatpickr('#request_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#required_date', { dateFormat: 'd/m/Y', allowInput: true });
if (request_id) {
+2 -2
View File
@@ -180,7 +180,7 @@
return;
}
$.each(rows, function(i, r) {
var items = typeof r.items === 'string' ? JSON.parse(r.items || '[]') : (r.items || []);
var item_count = parseInt(r.item_count) || 0;
var po_link = r.po_id > 0
? `<a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=${r.po_id}">${escape_html(r.po_number || ('PO #' + r.po_id))}</a>`
: '<span class="text-muted">—</span>';
@@ -190,7 +190,7 @@
<td class="py-3">${r.required_date ? format_date(r.required_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(r.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.grand_total, 2)}</td>
<td class="py-3">${status_badge[String(r.status)] || r.status}</td>
<td class="py-3">${po_link}</td>
+38
View File
@@ -0,0 +1,38 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/WarehouseManager.php';
// What one location holds — the quantity a stock-out or transfer from it moves
// (StockManager takes the whole approved stock-in row in the bin).
try {
$whMgmt = new WarehouseManager($pdo2, $company_id);
$row = $whMgmt->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));
?>
+20 -4
View File
@@ -17,11 +17,27 @@
$answer['output'] = $wh->getWarehouseList($type, $sku, $id);
}
if (empty($answer['output'])) {
if (!empty($answer['output'])) {
$answer['success'] = 1;
} elseif ($type === 'from' && !$id && $sku === '') {
// A source ("from") list is filtered to warehouses holding the chosen
// product, so it is empty until a product has been picked. That is the
// normal starting state of a stock-out form, not a failure. Reporting
// it as one showed "create your first warehouse" on every fresh
// stock-out, and the rejected request aborted the page's boot sequence
// before the barcode scanner was initialised.
$answer['success'] = 1;
} elseif ($type === 'from' && $sku !== '') {
$answer['success'] = 0;
$answer['message'] = $lot
? 'No approved stock of this product and lot is available in any warehouse.'
: 'No approved stock of this product is available in any warehouse.';
} else {
$answer['success'] = 0;
$answer['message'] = 'No warehouse found. Please go to <b>Inventory → Warehouse</b> to create your first warehouse before using this page.';
} else {
$answer['success'] = 1;
}
exit(json_encode($answer));
?>
?>
+36
View File
@@ -257,6 +257,31 @@
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val();
// Quantities are stored as decimal(18,4). Anything finer is rounded away
// on insert, so 0.0000001 used to become a movement of 0.0000 that still
// looked like a successful stock-in. Reject it here with a message that
// names the limit; StockManager enforces the same rule server-side.
<?php if (empty($_GET["id"])) { ?>
var quantity_num = parseFloat(quantity_val);
if (!(quantity_num > 0)) {
bootbox.alert('Please enter a quantity greater than zero.');
return Promise.resolve();
}
if (round_dp(quantity_num, 4) < 0.0001) {
bootbox.alert('Quantity ' + quantity_num + ' is smaller than the minimum the system records (0.0001).');
return Promise.resolve();
}
quantity_val = round_dp(quantity_num, 4);
// Expiry dates are stored on the lot, so one entered without a lot number
// has nowhere to go and would be dropped without warning.
if ($('#expiry_date').val() && !$('#lot_number').val().trim()) {
bootbox.alert('Enter a lot number — expiry dates are recorded against a lot.');
return Promise.resolve();
}
<?php } ?>
// Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) {
$('#zone').val(bin_val);
@@ -462,7 +487,18 @@
.val(data.expiry_date);
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
} else {
// No lot number, so there is nothing for an expiry date to hang
// off: expiry lives on md_lot, keyed by lot. Leaving the field
// empty but editable invited entering one that would be silently
// discarded on update, so say why it is unavailable instead.
if (expiryPicker) { expiryPicker.clear(); }
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry dates are recorded against a lot — this movement has no lot number')
.attr('placeholder', 'Not tracked — no lot number');
if (expiryPicker && expiryPicker.altInput) {
expiryPicker.altInput.disabled = true;
expiryPicker.altInput.placeholder = 'Not tracked — no lot number';
}
}
$('#product_sku').attr('secondary', data.product_sku);
$('#product_sku, #quantity, #price').prop('disabled', true);
+47 -1
View File
@@ -93,6 +93,16 @@
</select>
</div>
<!-- Quantity: a stock-out always takes everything in the location -->
<div class="mb-3 col-lg-6">
<label for="location_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="location_uom" style="min-width:60px;">&nbsp;</span>
</div>
<div class="form-text">The whole quantity in the selected location is taken out.</div>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
@@ -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('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_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('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
}
@@ -509,7 +550,7 @@
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
.val(data.serial_number || '').prop('disabled', true);
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').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);
<?php } ?>
</script>
+51
View File
@@ -92,6 +92,15 @@
<option value="">Please select bin</option>
</select>
</div>
<!-- Quantity: a transfer always moves everything in the from-location -->
<div class="mb-3 col-lg-3">
<label for="transfer_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="transfer_uom" style="min-width:52px;">&nbsp;</span>
</div>
<div class="form-text">Whole location is moved.</div>
</div>
<div class="col-12"><hr class="my-2"></div>
@@ -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('&nbsp;');
if (!$('#warehouse_from').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_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('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
}
@@ -590,6 +630,12 @@
action: 'read',
onSuccess: function(res) {
var data = res.output;
if (!data || !data.ref) {
bootbox.alert('Stock transfer not found.', function() {
window.location.href = '<?php echo $server_url?>ics/stock_transfer.php';
});
return;
}
var from_wh = data.ref.ref_warehouse;
var to_wh = data.ref_warehouse;
@@ -640,6 +686,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 +780,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'); }
+17 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
<select name="warehouse" id="warehouse" class="form-select"></select>
</div>
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr>
<th>Date</th>
<th>Reference</th>
<th>Warehouse</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -210,18 +211,21 @@
var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
@@ -234,7 +238,7 @@
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
<a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_in($(this),${item['id']})">
onclick="delete_stock_in($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i>
</a>
</td>
@@ -255,9 +259,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var option = ``;
// Default to every warehouse. Stock is stored one table per warehouse,
// so defaulting to a single one made a receipt that was split across
// warehouses look incomplete — a 4-line PO showed only the 3 lines that
// landed in the selected warehouse.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
})
$(`select#warehouse`).html(option);
}
@@ -265,7 +273,7 @@
}
function delete_stock_in(element, id) {
function delete_stock_in(element, id, warehouse_id) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_in.php",
@@ -274,7 +282,7 @@
action: 'delete',
data : {
id: id,
wh: $(`select#warehouse`).val()
wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
+16 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
<select name="warehouse" id="warehouse" class="form-select"></select>
</div>
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr>
<th>Date</th>
<th>Reference</th>
<th>Warehouse</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -151,7 +152,7 @@
}
var delete_btn = (!source)
? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']})">
? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i>
</a>`
: '';
@@ -237,18 +238,21 @@
var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
@@ -275,9 +279,12 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var option = ``;
// Default to every warehouse — stock is stored one table per
// warehouse, so a single-warehouse default hides movements that went
// elsewhere and makes a document look only partly processed.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
})
$(`select#warehouse`).html(option);
}
@@ -285,7 +292,7 @@
}
function delete_stock_out(element, id) {
function delete_stock_out(element, id, warehouse_id) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_out.php",
@@ -294,7 +301,7 @@
action: 'delete',
data : {
id: id,
wh: $(`select#warehouse`).val()
wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
+25 -1
View File
@@ -1,4 +1,28 @@
<?php
// Output buffering must be active before the first byte of HTML below, so that
// header() calls made later in the page still work — notably the
// not-logged-in redirect in include_topbar.php, which runs *after* this file
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
// entirely on php.ini's output_buffering: it is on for the dev stack but off
// in production, where every protected page answered 200 with a half-rendered
// body instead of sending the browser to the login form. session.php starts a
// buffer for the same reason.
if (ob_get_level() === 0) {
ob_start();
}
// Never render PHP notices/warnings into the page: they leak absolute server
// paths to anonymous visitors and corrupt the markup. Errors still reach the
// server log. This mirrors the policy db_auth.php already applies to the JSON
// API routes, and keeps the app safe even where php.ini has display_errors on.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Apply the configured application timezone. Pages that only require config.php
// (no dbconn.php) still call date() for default values such as "today", so they
// need this too or they render a UTC date.
require_once __DIR__ . '/assets/utils/timezone.php';
// Security headers — emitted before any HTML output.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
@@ -66,7 +90,7 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/main.css">
<script type="module" src="<?php echo $server_url?>assets/js/main.js"></script>
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
<script src="<?php echo $server_url?>assets/js/custom.js"></script>
<script src="<?php echo $server_url?>assets/js/custom.js?v=<?php echo @filemtime(__DIR__ . '/assets/js/custom.js'); ?>"></script>
<script src="<?php echo $server_url?>assets/js/batch_overlay.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"
+21 -1
View File
@@ -3,11 +3,17 @@
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/dbconn.php';
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/assets/utils/otp_policy.php';
// Redirect to login if the user has not completed full authentication.
// login_company_id is only written by login_confirm.php after OTP is verified —
// using it (not "otp") ensures half-logged-in sessions are also redirected.
if(empty($_SESSION["login_company_id"])){
// Discard the markup include_header.php has already buffered so the browser
// receives a clean redirect rather than a partially rendered page body.
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Location: '.$server_url.'login/index.php');
exit;
}
@@ -193,6 +199,18 @@ $_usage_full = $_usage_max_pct >= 100;
</li>
<?php endif; ?>
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
<?php if (!otp_required()): ?>
<li class="d-none d-md-block">
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
style="font-size:11px; cursor:default;"
title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-shield-off"></i>
OTP off
</span>
</li>
<?php endif; ?>
<!-- Usage limit warning -->
<?php if ($_usage_full || $_usage_warn): ?>
<li>
@@ -402,7 +420,9 @@ function do_switch_branch(company_id) {
autoPrepare: true,
checkRequired: 0,
action: 'update',
company_id: company_id,
// Sent under its own key: prepare_form_data() always fills company_id with
// the CURRENT company, and only options.data reaches the payload.
data: { target_company_id: company_id },
onSuccess: function(res) {
window.location.reload();
}
+27 -9
View File
@@ -117,16 +117,32 @@
};
function load_listing() {
var period = document.getElementById('f_period').value;
var period = document.getElementById('f_period').value; // "YYYY-MM"
var source = document.getElementById('f_source').value;
// The engine filters on a date range, not a period, so turn the chosen
// month into its first and last day.
var date_from = '', date_to = '';
if (/^\d{4}-\d{2}$/.test(period)) {
var ym = period.split('-');
var last_day = new Date(parseInt(ym[0]), parseInt(ym[1]), 0).getDate();
date_from = period + '-01';
date_to = period + '-' + ('0' + last_day).slice(-2);
}
// Payload fields must go inside `data` — ajax_request() ignores unknown
// top-level options, so as siblings of `url` these were never sent and the
// month / source filters silently did nothing.
ajax_request({
url: server_url + 'accounting/api/engine/get_journal_listing.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
period: period,
source_type: source,
data: {
source_type: source,
date_from: date_from,
date_to: date_to
},
onSuccess: function(res) {
var rows = res.output || [];
document.getElementById('jl_badge').textContent = rows.length + ' entries';
@@ -146,8 +162,8 @@
'<td><span class="badge rounded-pill ' + cls + ' small">' + escape_html(label) + '</span></td>' +
'<td class="small text-muted">' + escape_html(r.contact_name || '—') + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="text-end small">' + format_number(r.total_debit) + '</td>' +
'<td class="text-end small">' + format_number(r.total_credit) + '</td>' +
'<td class="text-end small">' + format_number(r.total_debit, 2) + '</td>' +
'<td class="text-end small">' + format_number(r.total_credit, 2) + '</td>' +
'<td class="small text-muted">' + escape_html(r.posted_at) + '</td>' +
'<td><a href="javascript:;" onclick="view_detail(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' +
(r.source_type === 'manual' ? ' <a href="' + server_url + 'journal/new.php?gl_id=' + r.id + '" class="ms-2"><i class="ti ti-edit fs-5"></i></a>' : '') +
@@ -169,7 +185,9 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
gl_id: gl_id,
// Inside `data`, or it is never sent and the engine answers
// "gl_id is required." for every row.
data: { gl_id: gl_id },
onSuccess: function(res) {
var d = res.output;
var h = d.header;
@@ -180,14 +198,14 @@
'<td>' + escape_html(l.account_code) + '</td>' +
'<td>' + escape_html(l.account_name) + '</td>' +
'<td class="text-muted small">' + escape_html(l.description || '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.debit) ? format_number(l.debit) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.credit) ? format_number(l.credit) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.debit) ? format_number(l.debit, 2) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.credit) ? format_number(l.credit, 2) : '—') + '</td>' +
'</tr>';
}).join('');
document.getElementById('gl_detail_body').innerHTML =
'<div class="row g-3 mb-4">' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(format_date(h.journal_date)) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Period</p><strong>' + escape_html(h.period) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Reference</p><strong>' + escape_html(h.reference || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Source</p><strong>' + escape_html(src_labels[h.source_type] || h.source_type) + '</strong></div>' +
+12 -6
View File
@@ -216,11 +216,17 @@
autoPrepare: true,
checkRequired: 0,
action: 'manage',
gl_id: document.getElementById('gl_id').value || 0,
journal_date: jdate,
reference: document.getElementById('reference').value,
description: document.getElementById('description').value,
lines: JSON.stringify(lines),
// Payload fields must go inside `data` — ajax_request() ignores unknown
// top-level options. As siblings of `url`, `lines` was never sent (it is
// not a form field, so autoPrepare could not pick it up either) and every
// save was refused with "At least two journal lines are required."
data: {
gl_id: document.getElementById('gl_id').value || 0,
journal_date: jdate,
reference: document.getElementById('reference').value,
description: document.getElementById('description').value,
lines: JSON.stringify(lines)
},
onSuccess: function() {
window.location.href = server_url + 'journal/index.php';
}
@@ -238,7 +244,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
gl_id: <?php echo (int)$_GET['gl_id']; ?>,
data: { gl_id: <?php echo (int)($_GET['gl_id'] ?? 0); ?> },
onSuccess: function(res) {
var d = res.output;
var h = d.header;
+20 -11
View File
@@ -58,6 +58,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check.
// so they never receive or enter an OTP. Admin/owner always go through this check,
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
// on the OTP screen when the switch was turned off.
if (empty($_SESSION['skip_otp'])) {
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
if (!otp_required()) {
if (!empty($user_id)) {
otp_log_bypass($user_id, 'login_confirm');
}
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
@@ -127,15 +134,17 @@ if (empty($_SESSION['skip_otp'])) {
// An explicit logout clears session_token to NULL, so back.php bypasses this.
//
// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's
// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and
// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is
// UTC (config.php's $time_zone is never applied via date_default_timezone_set()).
// Pulling the timestamp into PHP and comparing with strtotime()/time() silently
// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which
// made idle_seconds permanently negative and this check block every login,
// regardless of window size. Comparing inside MySQL sidesteps the mismatch
// without touching PHP's global timezone (which would ripple into every other
// date()/time() call in the app).
// own NOW()), not in PHP, because session_last_seen is written with MySQL's
// NOW() and so is best compared against it.
//
// This originally worked around a timezone mismatch: config.php's $time_zone was
// never applied via date_default_timezone_set(), so PHP ran on UTC while the
// MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing
// with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours
// in the future — which made idle_seconds permanently negative and blocked every
// login. assets/utils/timezone.php now applies $time_zone to PHP and pins both
// PDO connections to the same offset, so the mismatch is gone; comparing in SQL
// is kept because it is still the most direct way to read a NOW()-written column.
define('SESSION_ACTIVE_GRACE_SECONDS', 120);
$sth_active = $pdo1->prepare(
+9 -4
View File
@@ -62,6 +62,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
exit(json_encode($answer));
}
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
// Owners always require 2FA. Invited users (license='user') require 2FA only
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
// if their role in this company is admin or owner; staff/viewer go straight in.
$requires_otp = true;
if (($r['license'] ?? 'owner') !== 'owner') {
$requires_otp = otp_required();
if (!$requires_otp) {
otp_log_bypass($user_id, 'login_otp');
} elseif (($r['license'] ?? 'owner') !== 'owner') {
$sth_role = $pdo1->prepare(
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
);
+79 -64
View File
@@ -19,8 +19,10 @@
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 3. Decode and sanitise input fields.
* 4. Required field validation — company_name and channel_name must be non-empty.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
* provided while email OTP is on (company SMTP delivers the OTP). With
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
* steps 6-8 and 13 are skipped and the company is created without SMTP.
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
@@ -52,6 +54,7 @@ require_once '../../../session.php';
require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/otp_policy.php';
header('Content-Type: application/json; charset=utf-8');
@@ -97,9 +100,9 @@ try {
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
// channel_name is the URL slug / identifier — lowercase first, then strip
// everything except lowercase letters, digits, hyphens, and underscores.
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
@@ -114,59 +117,67 @@ try {
}
// ── Step 5: SMTP field validation ────────────────────────────────────────
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
// emails, and an account without working SMTP could not complete 2FA login.
// With OTP_REQUIRED=false in config.php it is optional — all three fields
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
// row (step 13) are skipped. Partly filled is an error either way.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
$answer['message'] = otp_required()
? 'SMTP configuration is required. Please fill in all SMTP fields.'
: 'Fill in SMTP host, username and password, or leave all three blank.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if ($smtp_given) {
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
}
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
@@ -226,25 +237,29 @@ try {
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
// can be added later under Settings → SMTP.
if ($smtp_given) {
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
}
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
+18 -2
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
require '../include_header.php';
// successful login — redirect based on app_access
if(!empty($_SESSION["login_status"])){
@@ -32,6 +33,13 @@
</div>
<form class="needs-validation mt-3" novalidate id="login-form">
<?php if (!otp_required()): ?>
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-alert-triangle me-1"></i>
Email OTP is off — sign-in is password only.
</div>
<?php endif; ?>
<!-- first step login [OTP] -->
<?php if(!isset($_SESSION['login_data'])){?>
<div class="mb-3">
@@ -51,7 +59,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- "Remember me" is intentionally excluded.
This login uses 2FA (OTP via email) on every session.
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
A persistent login would bypass the OTP step and undermine the security model.
Do not add this back. -->
</div>
@@ -62,6 +70,7 @@
</p>
<?php }else{ ?>
<!-- second step login -->
<?php if (otp_required()): ?>
<div class="alert alert-warning small py-2 mb-3">
<i class="ti ti-mail me-1"></i>
OTP is sent via your company's SMTP setting.
@@ -73,13 +82,20 @@
<span>One Time Password</span>
</label>
<input id="otp" type="otp" class="form-control"
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
</div>
<?php else: ?>
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
login_confirm.php no longer checks the code, so there is nothing to type. -->
<input id="otp" type="hidden" value="">
<?php endif; ?>
<div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between">
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
<?php if (otp_required()): ?>
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
<?php endif; ?>
</label>
</div>
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
+29 -8
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
// Must come from email verification
if (empty($_SESSION['onboarding_user_id'])) {
@@ -48,7 +49,8 @@
</div>
<div class="col-md-6">
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3">
@@ -77,11 +79,20 @@
<div class="d-flex justify-content-between align-items-start mb-1">
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
<?php if (otp_required()): ?>
<span class="badge bg-label-danger">Required</span>
<?php else: ?>
<span class="badge bg-label-secondary">Optional</span>
<?php endif; ?>
</div>
<p class="text-muted small mb-3">
<?php if (otp_required()): ?>
SMTP is required to send OTP during login.
A verification email will be sent when you finish setup.
<?php else: ?>
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
you can add it later under Settings → SMTP.
<?php endif; ?>
</p>
<!-- SMTP User Guide (collapsible) -->
@@ -174,11 +185,11 @@
<!-- SMTP Form -->
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
</div>
<div class="col-md-4">
<label class="form-label">Port <span class="text-danger">*</span></label>
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<select class="form-select" id="smtp_port">
<option value="587">587 — TLS</option>
<option value="465">465 — SSL</option>
@@ -186,11 +197,11 @@
</select>
</div>
<div class="col-md-6">
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
</div>
<div class="col-md-6">
<label class="form-label">Password <span class="text-danger">*</span></label>
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<div class="input-group">
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
@@ -251,13 +262,23 @@
return;
}
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
bootbox.alert('SMTP host, username and password are required.');
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
// with OTP_REQUIRED=false it is optional, but the three fields go together.
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
const smtp_host = $('#smtp_host').val().trim();
const smtp_user = $('#smtp_username').val().trim();
const smtp_pass = $('#smtp_password').val();
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
bootbox.alert(smtp_required
? 'SMTP host, username and password are required.'
: 'Fill in SMTP host, username and password, or leave all three blank.');
return;
}
const $btn = $('#btn_finish');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
const encryption = $('input[name="smtp_encryption"]:checked').val();
-47
View File
@@ -1,47 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php';
require '../../../assets/utils/classes/StockManager.php';
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/InvoiceManager.php';
require_once '../../../assets/utils/classes/CompanySettingManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid order ID.';
http_response_code(400);
exit(json_encode($answer));
}
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
$auto_invoice = (int)$csm->get('auto_invoice_and_credit_note') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_approve, $auto_invoice) {
$order = new OrderManager($pdo, $company_id);
$order->confirmOrder($id, $uuid, $logging, $auto_approve);
if ($auto_invoice) {
$invMgmt = new InvoiceManager($pdo, $company_id);
$invMgmt->createFromOrder($id, $logging);
}
});
$answer['success'] = 1;
$answer['message'] = 'Order confirmed.';
$answer['auto_approved'] = $auto_approve;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+1 -1
View File
@@ -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';
+3 -3
View File
@@ -341,7 +341,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
@@ -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);
@@ -712,7 +712,7 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
await retrieve_warehouses();
if (order_id) {
+1 -1
View File
@@ -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));
+1 -2
View File
@@ -241,8 +241,7 @@
var body = '';
$.each(page_data, function(i, o) {
var items = JSON.parse(o.items || '[]');
var item_count = items.length;
var item_count = parseInt(o.item_count) || 0;
var can_edit = parseInt(o.status) === 0;
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
+2 -2
View File
@@ -158,7 +158,7 @@
var body = '';
$.each(page_data, function(i, r) {
var items = JSON.parse(r.items || '[]');
var item_count = parseInt(r.item_count) || 0;
var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1;
body += `<tr>
@@ -167,7 +167,7 @@
<td class="py-3">${r.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${format_date(r.return_date)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.refund_amount, 2)}</td>
<td class="py-3">${return_status_badge(r.status)}</td>
<td class="py-3">${receipt_status_badge(r.receipt_status)}</td>
+27 -9
View File
@@ -326,7 +326,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || 0), 2)}</td>
@@ -527,9 +527,9 @@
<span class="text-muted ms-2 small">${item.product_name || ''}</span>
</div>
<div class="d-flex gap-3 text-muted small">
<span>Ordered: <strong>${format_number(item.ordered_qty, 0)}</strong></span>
<span>Received: <strong>${format_number(item.received_qty, 0)}</strong></span>
<span>Remaining: <strong class="text-primary">${format_number(item.remaining_qty, 0)}</strong></span>
<span>Ordered: <strong>${format_quantity(item.ordered_qty)}</strong></span>
<span>Received: <strong>${format_quantity(item.received_qty)}</strong></span>
<span>Remaining: <strong class="text-primary">${format_quantity(item.remaining_qty)}</strong></span>
</div>
</div>
@@ -546,7 +546,7 @@
<div class="col-lg-3">
<label class="form-label small text-muted">Receiving Qty <span class="text-danger">*</span></label>
<input type="number" id="recv_qty_${rowId}" class="form-control form-control-sm"
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="any">
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
</div>
<div class="col-lg-3">
@@ -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 || '');
@@ -770,6 +770,15 @@
var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0;
if (qty <= 0) return;
// Received quantities are stored as decimal(18,4). A value finer than
// that is rounded away on insert, so 0.0000001 was accepted, created a
// stock movement of 0.0000, and still left the PO showing "Partial".
if (round_dp(qty, 4) < 0.0001) {
errors.push(`${$card.data('sku')}: receiving quantity ${qty} is smaller than the minimum the system records (0.0001).`);
return;
}
qty = round_dp(qty, 4);
var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0;
var bin = $(`#recv_bin_${rowId}`).val() || '';
@@ -791,13 +800,22 @@
// flatpickr with altInput: the real (hidden) input holds the ISO value
var expiry_val = $(`#recv_expiry_${rowId}`).val() || '';
var lot_val = $(`#recv_lot_${rowId}`).val().trim();
// Expiry dates are stored on md_lot, keyed by lot number — one entered
// without a lot has nowhere to go and was dropped without warning, so
// the received stock then showed no expiry at all.
if (expiry_val && !lot_val) {
errors.push(`${$card.data('sku')}: enter a lot number — expiry dates are recorded against a lot.`);
return;
}
receive_items.push({
item_id: parseInt($card.data('item-id')),
product_sku: $card.data('sku'),
warehouse_id: wh_id,
quantity: qty,
lot_number: $(`#recv_lot_${rowId}`).val().trim(),
lot_number: lot_val,
expiry_date: expiry_val,
serial_number: $(`#recv_serial_${rowId}`).val().trim(),
zone: zone,
@@ -974,7 +992,7 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
await load_location_config();
await retrieve_warehouses();
+19 -2
View File
@@ -160,6 +160,8 @@
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
@@ -226,7 +228,12 @@
$('#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) : '');
// A due date before the issue date is not a valid payment term, so
// stop the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
@@ -311,6 +318,12 @@
}
function save_invoice() {
var due_val = $('#due_date').val().trim();
if (due_val && issued_date && to_iso_date(due_val) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
@@ -346,6 +359,10 @@
bootbox.alert('Please enter a due date before issuing this purchase invoice.');
return;
}
if (!is_dn && due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice';
bootbox.confirm({
message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?',
@@ -419,8 +436,8 @@
$(function() {
load_dept_cache();
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
+1 -1
View File
@@ -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));
+1 -2
View File
@@ -226,8 +226,7 @@
var body = '';
$.each(page_data, function(i, o) {
var items = JSON.parse(o.items || '[]');
var item_count = items.length;
var item_count = parseInt(o.item_count) || 0;
var can_cancel = parseInt(o.status) >= -2 && parseInt(o.status) <= 1;
body += `<tr>
+1 -3
View File
@@ -218,9 +218,7 @@
}
$.each(rows, function(i, r) {
var items = [];
try { items = JSON.parse(r.items || '[]'); } catch(e) {}
var item_count = items.length;
var item_count = parseInt(r.item_count) || 0;
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(r.return_number || '')}</td>
<td class="py-3">
@@ -88,6 +88,7 @@ foreach ($convert_items as $ci) {
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => (float)($qi['tax_rate'] ?? 0),
'stock_out_id' => 0,
];
$validated[] = ['item_id' => $item_id, 'quantity' => $qty];
+27 -3
View File
@@ -146,7 +146,9 @@
<?php require '../include_ending.php'; ?>
<script>
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_id = <?php echo $invoice_id; ?>;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
@@ -207,7 +209,12 @@
$('#display_contact').html(display_text(inv.contact_name));
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
// A due date before the issue date is not a valid payment term, so stop
// the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
var rows = '';
@@ -279,6 +286,18 @@
}
function save_invoice() {
// autoPrepare sweeps every .form-control into the payload, so #due_date
// arrives as the picker's DD/MM/YYYY text. Sent unconverted it reaches a
// MySQL DATE column verbatim and the insert fails, which the engine
// reports as the opaque "Database error, please try again." Convert it
// here, the way the purchase-invoice page already does.
var due_date = $('#due_date').val().trim();
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
@@ -286,6 +305,7 @@
action: 'update',
data: {
id: invoice_id,
due_date: due_date ? to_iso_date(due_date) : '',
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
@@ -320,6 +340,10 @@
bootbox.alert('Please enter a due date before issuing this invoice.');
return;
}
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
bootbox.confirm({
message: 'Issue this invoice?',
buttons: {
@@ -390,8 +414,8 @@
}
$(function() {
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
+2 -2
View File
@@ -235,7 +235,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end align-middle">
@@ -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);
+3 -3
View File
@@ -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);
@@ -515,7 +515,7 @@
// ── Boot ──────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
if (quotation_id) {
await retrieve_quotation();
} else {
+1 -7
View File
@@ -165,12 +165,6 @@
return map[String(status)] || '<span class="badge bg-light text-dark">—</span>';
}
function order_items_count(items) {
if (Array.isArray(items)) return items.length;
try { return JSON.parse(items || '[]').length; }
catch(e) { return 0; }
}
function retrieve_orders() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
@@ -226,7 +220,7 @@
}
$.each(rows, function(i, o) {
var item_count = order_items_count(o.items);
var item_count = parseInt(o.item_count) || 0;
var source = String(o.source || '');
var source_display = source === 'quotation' && parseInt(o.source_id) > 0
? `<a href="<?php echo $server_url?>revenue/manage_quotation.php?id=${o.source_id}">Quotation #${o.source_id}</a>`
+2 -2
View File
@@ -212,14 +212,14 @@
var body = '';
$.each(page_data, function(i, q) {
var items = typeof q.items === 'string' ? JSON.parse(q.items || '[]') : (q.items || []);
var item_count = parseInt(q.item_count) || 0;
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(q.quotation_number)}</td>
<td class="py-3">${format_date(q.quotation_date)}</td>
<td class="py-3">${q.valid_until ? format_date(q.valid_until) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(q.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(q.department_id)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(q.grand_total, 2)}</td>
<td class="py-3">${status_badge[String(q.status)] || q.status}</td>
<td class="py-3">
+6
View File
@@ -2,6 +2,12 @@
// app/session.php
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
// The buffer is still flushed, so notices would still land in front of the JSON
// body and break the client's parse ("Server error occurred."). The login API
// engines load this file instead of db_auth.php, so apply the same policy here.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (session_status() === PHP_SESSION_NONE) {
// Derive cookie path dynamically from the current script location.
+11 -2
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/app_registry.php';
require_once '../../../assets/utils/classes/UserManager.php';
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
@@ -30,7 +31,11 @@
$result = $um->inviteUser($email, $role, $app_access);
// Both new and existing users require explicit acceptance via email
$invite_url = rtrim($server_url, '/') . ($result['new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
@@ -88,7 +93,11 @@
$map_id = (int)($data['map_id'] ?? 0);
$result = $um->resendInvite($map_id);
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['is_new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
+6 -2
View File
@@ -3,7 +3,11 @@
* switch_branch.php — Switch the active company for the current session.
*
* action: 'read' → return list of companies the user belongs to
* action: 'update' → switch to the requested company_id
* action: 'update' → switch to target_company_id
*
* The target is read from target_company_id, not company_id: every request
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
* so reading it made a switch silently re-select the company already active.
*/
session_start();
require_once '../../../assets/utils/db_auth.php';
@@ -20,7 +24,7 @@ if ($action === 'read') {
}
if ($action === 'update') {
$target_company_id = (int)($data['company_id'] ?? 0);
$target_company_id = (int)($data['target_company_id'] ?? 0);
if (!$target_company_id) {
$answer['message'] = 'Invalid company.';
+2 -1
View File
@@ -121,7 +121,8 @@
<div class="col-md-6 mb-3">
<label class="form-label">Nickname / Channel Name</label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3 mb-3">
+2 -3
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require '../config.php';
require_once '../assets/utils/app_registry.php';
require '../include_header.php';
?>
@@ -256,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;
+4 -2
View File
@@ -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);
+8 -3
View File
@@ -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');
+46 -173
View File
@@ -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,
+1
View File
@@ -22,6 +22,7 @@ services:
EMIT_SECRET: ${EMIT_SECRET}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
OTP_REQUIRED: ${OTP_REQUIRED:-false}
volumes:
- .:/var/www/html/wms-app
ports:
+1
View File
@@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host
EMIT_SECRET=$emit_secret
SMTP_USERNAME=$smtp_user
SMTP_PASSWORD=$smtp_pass
OTP_REQUIRED=false
HTTP_PORT=$http_port
EOF
chmod 600 "$ENV_FILE"
-23
View File
@@ -1,23 +0,0 @@
# Serves the app at the vhost root instead of under /wms-app/.
# Mounted over the image's default site by docker-compose.override.yml.
<VirtualHost *:80>
DocumentRoot /var/www/html/wms-app
<Directory /var/www/html/wms-app>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Never serve deploy artefacts or seeders over HTTP.
<FilesMatch "^(\.env|docker-compose(\.override)?\.yml|.*\.log|setup\.php|demo_seed.*\.php|finish-deploy\.sh)$">
Require all denied
</FilesMatch>
# nginx terminates TLS upstream; let PHP know the real scheme so the app
# does not emit http:// URLs into an https page.
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
+16
View File
@@ -33,6 +33,22 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
// Off by default: anything other than the boolean true leaves the OTP step off.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', ${OTP_REQUIRED});
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to (Setting → Users Access). Keys must match
// the user.app_access enum; assets/utils/app_registry.php supplies this default
// for configs that do not define it.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
$packages = [
'starter' => [
+25 -1
View File
@@ -4,15 +4,39 @@ set -e
APP_DIR=/var/www/html/wms-app
CONFIG=$APP_DIR/app/config.php
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
# on; a missing variable or anything else means false.
: "${OTP_REQUIRED:=false}"
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
export OTP_REQUIRED
# Generate app/config.php from template on first run only.
# Restrict envsubst to known placeholders so it never touches the app's own
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
if [ ! -f "$CONFIG" ]; then
echo "[entrypoint] generating app/config.php"
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
< /usr/local/etc/wms/config.php.template > "$CONFIG"
fi
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
# line reconciled on every start: the .env value always wins, and a config.php
# written before this switch existed gets the line added.
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
fi
else
# Drop a closing ?> on the last line so the appended block stays inside PHP.
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
fi
if [ "$OTP_REQUIRED" = "false" ]; then
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
fi
mkdir -p "$APP_DIR/app/uploads"
chown -R www-data:www-data "$APP_DIR/app/uploads"
-21
View File
@@ -1,21 +0,0 @@
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [NODE-CRON] [WARN] missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
-58
View File
@@ -1,58 +0,0 @@
2026-07-20T17:35:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-20T21:00:00: [scheduler] etl_gl slot=21
2026-07-20T21:00:00: [scheduler] etl_gl slot=21 — no companies
2026-07-21T16:06:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-22T13:08:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [NODE-CRON] [WARN] missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-22T15:00:00: [scheduler] etl_gl slot=15
2026-07-22T15:00:00: [scheduler] etl_gl slot=15 — no companies
2026-07-22T15:30:00: [scheduler] etl_stock slot=15
2026-07-22T15:30:00: [scheduler] etl_stock slot=15 — no companies
2026-07-22T16:00:00: [scheduler] etl_gl slot=16
2026-07-22T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-07-23T12:11:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-23T12:30:00: [scheduler] etl_stock slot=12
2026-07-23T12:30:00: [scheduler] etl_stock slot=12 — no companies
2026-07-23T13:36:04: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T15:30:00: [scheduler] etl_stock slot=15
2026-07-23T15:30:00: [scheduler] etl_stock slot=15 — no companies
2026-07-23T16:00:00: [scheduler] etl_gl slot=16
2026-07-23T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-07-23T16:30:00: [scheduler] etl_stock slot=16
2026-07-23T16:30:00: [scheduler] etl_stock slot=16 — no companies
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:30:00: [scheduler] etl_stock slot=21
2026-07-23T21:30:00: [scheduler] etl_stock slot=21 — no companies
2026-07-25T12:19:55: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-25T12:30:00: [scheduler] etl_stock slot=12
2026-07-25T12:30:00: [scheduler] etl_stock slot=12 — no companies
2026-07-25T13:00:00: [scheduler] etl_gl slot=13
2026-07-25T13:00:00: [scheduler] etl_gl slot=13 — no companies
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T08:52:15: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-08-03T09:00:00: [scheduler] etl_gl slot=9
2026-08-03T09:00:00: [scheduler] alert_overdue_invoices running
2026-08-03T09:00:00: [scheduler] etl_gl slot=9 — no companies
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
-255
View File
@@ -1,255 +0,0 @@
2026-07-20T17:35:51: Node.js real-time server running on port 3000
2026-07-21T16:06:58: Node.js real-time server running on port 3000
2026-07-22T13:08:58: Node.js real-time server running on port 3000
2026-07-22T13:10:29: [connect] socket=0-LquhazY9eKKN7LAAAB company=1 user=1 role=owner
2026-07-22T13:15:09: [disconnect] socket=0-LquhazY9eKKN7LAAAB
2026-07-22T13:15:12: [connect] socket=P4rxlPGuHWMqT35XAAAD company=1 user=1 role=owner
2026-07-22T13:26:21: [disconnect] socket=P4rxlPGuHWMqT35XAAAD
2026-07-22T13:26:23: [connect] socket=EBxi3tj8fNMUmyoyAAAF company=1 user=1 role=owner
2026-07-22T13:30:24: [disconnect] socket=EBxi3tj8fNMUmyoyAAAF
2026-07-22T13:30:25: [connect] socket=SJtRh1L6usnHrWebAAAH company=1 user=1 role=owner
2026-07-22T13:31:59: [disconnect] socket=SJtRh1L6usnHrWebAAAH
2026-07-22T13:31:59: [connect] socket=xvRdZhqqg-soDWr7AAAJ company=1 user=1 role=owner
2026-07-22T13:41:17: [disconnect] socket=xvRdZhqqg-soDWr7AAAJ
2026-07-22T13:41:17: [connect] socket=QZkAkh2pHOGltp-0AAAL company=1 user=1 role=owner
2026-07-22T13:42:28: [disconnect] socket=QZkAkh2pHOGltp-0AAAL
2026-07-22T13:42:28: [connect] socket=0Vb5YTMHDs1gOC47AAAN company=1 user=1 role=owner
2026-07-22T13:42:36: [disconnect] socket=0Vb5YTMHDs1gOC47AAAN
2026-07-22T13:42:36: [connect] socket=idQO9l1OGLiXPyEoAAAP company=1 user=1 role=owner
2026-07-22T13:42:43: [disconnect] socket=idQO9l1OGLiXPyEoAAAP
2026-07-22T13:42:43: [connect] socket=nvfS_4EF_3kF5PGsAAAR company=1 user=1 role=owner
2026-07-22T13:47:25: [disconnect] socket=nvfS_4EF_3kF5PGsAAAR
2026-07-22T13:47:27: [connect] socket=yytX3fzh594f9phBAAAT company=1 user=1 role=owner
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T14:03:26: [disconnect] socket=yytX3fzh594f9phBAAAT
2026-07-22T14:03:28: [connect] socket=waEM4mo4V099RHhAAAAV company=1 user=1 role=owner
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:11:10: [disconnect] socket=waEM4mo4V099RHhAAAAV
2026-07-22T14:35:14: [connect] socket=ZSmIs38dJh1u4If4AAAX company=1 user=1 role=owner
2026-07-22T14:35:49: [disconnect] socket=ZSmIs38dJh1u4If4AAAX
2026-07-22T14:35:49: [connect] socket=1bcHdQOC7cBTftxyAAAZ company=1 user=1 role=owner
2026-07-22T14:36:37: [disconnect] socket=1bcHdQOC7cBTftxyAAAZ
2026-07-22T14:36:38: [connect] socket=fMazKVGWKhaTm7OUAAAb company=1 user=1 role=owner
2026-07-22T14:36:46: [disconnect] socket=fMazKVGWKhaTm7OUAAAb
2026-07-22T14:36:46: [connect] socket=VfqVaEiOI3NTCA7fAAAd company=1 user=1 role=owner
2026-07-22T14:36:48: [disconnect] socket=VfqVaEiOI3NTCA7fAAAd
2026-07-22T14:36:48: [connect] socket=DAww8irzEgS7j7JLAAAf company=1 user=1 role=owner
2026-07-22T14:36:49: [disconnect] socket=DAww8irzEgS7j7JLAAAf
2026-07-22T14:36:49: [connect] socket=WgF3HArg1rthWkktAAAh company=1 user=1 role=owner
2026-07-22T14:37:05: [disconnect] socket=WgF3HArg1rthWkktAAAh
2026-07-22T14:37:05: [connect] socket=pfMLLNR0x-qoq485AAAj company=1 user=1 role=owner
2026-07-22T14:37:08: [disconnect] socket=pfMLLNR0x-qoq485AAAj
2026-07-22T14:37:09: [connect] socket=7ZtkCaJZqcYXBSZWAAAl company=1 user=1 role=owner
2026-07-22T14:37:14: [disconnect] socket=7ZtkCaJZqcYXBSZWAAAl
2026-07-22T14:37:14: [connect] socket=F6_OvPrmc-vv_KoqAAAn company=1 user=1 role=owner
2026-07-22T14:37:18: [disconnect] socket=F6_OvPrmc-vv_KoqAAAn
2026-07-22T14:37:18: [connect] socket=gdlZxPbkkcdkdE2AAAAp company=1 user=1 role=owner
2026-07-22T14:40:08: [disconnect] socket=gdlZxPbkkcdkdE2AAAAp
2026-07-22T14:40:09: [connect] socket=c8j8ybp-e7MPpa6cAAAr company=1 user=1 role=owner
2026-07-22T14:40:13: [disconnect] socket=c8j8ybp-e7MPpa6cAAAr
2026-07-22T14:40:13: [connect] socket=D36DmJgraENmU5xsAAAt company=1 user=1 role=owner
2026-07-22T14:40:20: [disconnect] socket=D36DmJgraENmU5xsAAAt
2026-07-22T14:40:21: [connect] socket=saY0q6gBioHWgq7RAAAv company=1 user=1 role=owner
2026-07-22T14:40:25: [disconnect] socket=saY0q6gBioHWgq7RAAAv
2026-07-22T14:40:25: [connect] socket=o9h4_9i-KOjDfVmrAAAx company=1 user=1 role=owner
2026-07-22T14:40:27: [disconnect] socket=o9h4_9i-KOjDfVmrAAAx
2026-07-22T14:40:27: [connect] socket=aIM89idl78lyhTbNAAAz company=1 user=1 role=owner
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T15:21:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'transfer' }
2026-07-22T15:28:23: [disconnect] socket=aIM89idl78lyhTbNAAAz
2026-07-22T15:43:35: [connect] socket=q1N4BECKfiomOEfyAAA1 company=1 user=1 role=owner
2026-07-22T15:43:38: [disconnect] socket=q1N4BECKfiomOEfyAAA1
2026-07-22T15:43:38: [connect] socket=yM_0j72uX0V2b-GuAAA3 company=1 user=1 role=owner
2026-07-22T15:43:43: [disconnect] socket=yM_0j72uX0V2b-GuAAA3
2026-07-22T15:43:43: [connect] socket=dvzUKq0p7lXQMuSLAAA5 company=1 user=1 role=owner
2026-07-22T15:43:45: [disconnect] socket=dvzUKq0p7lXQMuSLAAA5
2026-07-22T15:43:45: [connect] socket=TYvNpQgcOHR5-V1RAAA7 company=1 user=1 role=owner
2026-07-22T15:43:46: [disconnect] socket=TYvNpQgcOHR5-V1RAAA7
2026-07-22T15:43:46: [connect] socket=waNTeqU5sAu8W2jqAAA9 company=1 user=1 role=owner
2026-07-22T16:05:01: [disconnect] socket=waNTeqU5sAu8W2jqAAA9
2026-07-23T12:11:51: Node.js real-time server running on port 3000
2026-07-23T12:13:27: [connect] socket=NYO9Tg4V6Cqp3e4cAAAB company=1 user=1 role=owner
2026-07-23T12:33:05: [disconnect] socket=NYO9Tg4V6Cqp3e4cAAAB
2026-07-23T12:33:06: [connect] socket=pEDe8Dms2dU7gdhCAAAD company=1 user=1 role=owner
2026-07-23T12:34:13: [disconnect] socket=pEDe8Dms2dU7gdhCAAAD
2026-07-23T12:34:17: [connect] socket=vaGUT12vPXkqH_7kAAAF company=1 user=1 role=owner
2026-07-23T12:55:49: [disconnect] socket=vaGUT12vPXkqH_7kAAAF
2026-07-23T13:36:04: Node.js real-time server running on port 3000
2026-07-23T13:36:55: [connect] socket=nVGY71aXPas0zYS_AAAB company=1 user=1 role=owner
2026-07-23T13:51:36: [disconnect] socket=nVGY71aXPas0zYS_AAAB
2026-07-23T13:51:36: [connect] socket=5peGIWbSaRCxnlYBAAAD company=1 user=1 role=owner
2026-07-23T13:51:38: [disconnect] socket=5peGIWbSaRCxnlYBAAAD
2026-07-23T13:51:38: [connect] socket=fGwRmZaYGzedlqqRAAAF company=1 user=1 role=owner
2026-07-23T13:51:40: [disconnect] socket=fGwRmZaYGzedlqqRAAAF
2026-07-23T13:51:40: [connect] socket=YZk7xLKHpmi29ykIAAAH company=1 user=1 role=owner
2026-07-23T13:51:41: [disconnect] socket=YZk7xLKHpmi29ykIAAAH
2026-07-23T13:51:41: [connect] socket=f078x01mtX2eGd2HAAAJ company=1 user=1 role=owner
2026-07-23T13:57:26: [disconnect] socket=f078x01mtX2eGd2HAAAJ
2026-07-23T13:57:28: [connect] socket=vToy_ZVIAk6w9099AAAL company=1 user=1 role=owner
2026-07-23T14:17:58: [disconnect] socket=vToy_ZVIAk6w9099AAAL
2026-07-23T14:18:00: [connect] socket=7yHLdkKxBAAO_nF4AAAN company=1 user=1 role=owner
2026-07-23T16:25:41: [disconnect] socket=7yHLdkKxBAAO_nF4AAAN
2026-07-25T12:19:55: Node.js real-time server running on port 3000
2026-08-03T08:52:15: Node.js real-time server running on port 3000
2026-08-03T10:17:41: [connect] socket=kNyFq-T_JVC3-_WLAAAB company=1 user=1 role=owner
2026-08-03T10:18:50: [disconnect] socket=kNyFq-T_JVC3-_WLAAAB
2026-08-03T10:18:50: [connect] socket=efcou9_hk0YUTnvQAAAD company=1 user=1 role=owner
2026-08-03T10:18:59: [disconnect] socket=efcou9_hk0YUTnvQAAAD
2026-08-03T10:18:59: [connect] socket=PmKuy_3CCIDze4P3AAAF company=1 user=1 role=owner
2026-08-03T10:32:14: [disconnect] socket=PmKuy_3CCIDze4P3AAAF
2026-08-03T10:32:18: [connect] socket=-ZlIPBBmpRazD30gAAAH company=1 user=1 role=owner
2026-08-03T10:49:23: [disconnect] socket=-ZlIPBBmpRazD30gAAAH
2026-08-03T10:49:26: [connect] socket=Azbj9ipTPqb3aOW3AAAJ company=1 user=1 role=owner
2026-08-03T10:54:19: [disconnect] socket=Azbj9ipTPqb3aOW3AAAJ
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
2026-08-04T13:35:14: Node.js real-time server running on port 3000
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
if ($argc !== 2) {
fwrite(STDERR, "Usage: php adjust_docx_whitespace.php <document.docx>\n");
exit(2);
}
$path = $argv[1];
$wordNs = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException("Cannot open DOCX: {$path}");
}
$documentXml = $zip->getFromName('word/document.xml');
if ($documentXml === false) {
throw new RuntimeException('word/document.xml is missing');
}
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->preserveWhiteSpace = false;
$doc->formatOutput = false;
if (!$doc->loadXML($documentXml)) {
throw new RuntimeException('word/document.xml is invalid');
}
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('w', $wordNs);
function attr(DOMElement $element, string $name, string $value, string $ns): void
{
$element->setAttributeNS($ns, 'w:' . $name, $value);
}
function child(DOMDocument $doc, DOMElement $parent, string $localName, string $ns): DOMElement
{
foreach ($parent->childNodes as $node) {
if ($node instanceof DOMElement && $node->namespaceURI === $ns && $node->localName === $localName) {
return $node;
}
}
$element = $doc->createElementNS($ns, 'w:' . $localName);
$parent->appendChild($element);
return $element;
}
foreach ($xpath->query('//w:body/w:p | //w:body/w:tbl//w:p') as $paragraph) {
if (!$paragraph instanceof DOMElement) {
continue;
}
$text = '';
foreach ($xpath->query('.//w:t', $paragraph) as $textNode) {
$text .= $textNode->textContent;
}
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
$pPrNode = $xpath->query('./w:pPr', $paragraph)->item(0);
if (!$pPrNode instanceof DOMElement) {
$pPrNode = $doc->createElementNS($wordNs, 'w:pPr');
$paragraph->insertBefore($pPrNode, $paragraph->firstChild);
}
$spacing = child($doc, $pPrNode, 'spacing', $wordNs);
$before = '0';
$after = '30';
$line = '320';
if ($text === '') {
$after = '20';
$line = '120';
} elseif (preg_match('/^[1-7]\.\s/u', $text)) {
$before = '120';
$after = '45';
$line = '330';
} elseif (preg_match('/^[1-7]\.\d\s/u', $text)) {
$before = '70';
$after = '30';
$line = '320';
} elseif (str_starts_with($text, '•')) {
$after = '15';
$line = '310';
} elseif (str_starts_with($text, 'วันที่ 5 มกราคม')) {
$before = '40';
$after = '100';
$line = '330';
} elseif (str_starts_with($text, 'เรื่อง') || str_starts_with($text, 'เรียน')) {
$after = '55';
$line = '330';
} elseif (str_starts_with($text, 'ลงชื่อ')) {
$before = '160';
$after = '10';
$line = '310';
} elseif (str_starts_with($text, '(') || str_starts_with($text, 'ตำแหน่ง') || str_starts_with($text, 'วันที่ _') || str_starts_with($text, 'ผู้มีอำนาจ')) {
$after = '10';
$line = '310';
} elseif (str_starts_with($text, 'บริษัท บี.อาร์.เอ็น.') || str_starts_with($text, 'เอกสารฉบับนี้') || str_starts_with($text, 'ผลส่งมอบประกอบ') || str_starts_with($text, 'การเปลี่ยนแปลง') || str_starts_with($text, 'จึงจัดทำ')) {
$after = '55';
$line = '330';
}
attr($spacing, 'before', $before, $wordNs);
attr($spacing, 'after', $after, $wordNs);
attr($spacing, 'line', $line, $wordNs);
attr($spacing, 'lineRule', 'auto', $wordNs);
}
foreach ($xpath->query('//w:sectPr/w:pgMar') as $margin) {
if (!$margin instanceof DOMElement) {
continue;
}
attr($margin, 'top', '850', $wordNs);
attr($margin, 'bottom', '850', $wordNs);
attr($margin, 'left', '1080', $wordNs);
attr($margin, 'right', '1080', $wordNs);
attr($margin, 'header', '400', $wordNs);
attr($margin, 'footer', '400', $wordNs);
}
foreach ($xpath->query('//w:tbl/w:tblPr') as $tableProperties) {
if (!$tableProperties instanceof DOMElement) {
continue;
}
$margins = child($doc, $tableProperties, 'tblCellMar', $wordNs);
foreach (['top' => '45', 'left' => '70', 'bottom' => '45', 'right' => '70'] as $side => $width) {
$edge = child($doc, $margins, $side, $wordNs);
attr($edge, 'w', $width, $wordNs);
attr($edge, 'type', 'dxa', $wordNs);
}
}
$updatedXml = $doc->saveXML();
if ($updatedXml === false) {
throw new RuntimeException('Cannot serialize adjusted document XML');
}
if (!$zip->addFromString('word/document.xml', $updatedXml)) {
throw new RuntimeException('Cannot update word/document.xml');
}
$zip->close();
echo $path . PHP_EOL;
+1 -39
View File
@@ -14,16 +14,6 @@ if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then
npx --prefix "$TOOL_DIR" playwright install chromium
fi
# The Software Design figures are drawn in scripts/sdlc-seed/figures.mjs and
# rasterised with the document font before the seed copies them into sdlc/.
echo "Rendering Software Design figures..."
node "$TOOL_DIR/render-figures.mjs"
# sdlc/ is generated output: the seed rebuilds every work product from the
# controlled project data in scripts/sdlc-seed/ before anything is rendered.
echo "Seeding work products from project data..."
node "$SCRIPT_DIR/sdlc-seed/build.mjs"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
@@ -32,35 +22,7 @@ while IFS= read -r -d '' source; do
destination="$STAGING_DIR/${relative%.md}.pdf"
echo "Rendering ${relative%.md}.pdf"
node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
done < <(find "$SOURCE_DIR" -mindepth 2 -type f -name '*.md' -print0 | sort -z)
# Every .pdf gets a Word copy beside it, reconstructed from the rendered page.
# The CI header bands need Playwright, so they are rendered here with node and
# handed to the converter, which then only needs Python with pdf2docx: the local
# interpreter ($PYTHON) when it has it, otherwise a throwaway python container.
BAND_DIR="$(mktemp -d)"
trap 'rm -rf "$BAND_DIR"' EXIT
echo "Rendering CI header bands..."
node "$TOOL_DIR/render-ci-band.mjs" "$SOURCE_DIR" "$BAND_DIR"
echo "Converting PDFs to Word..."
PYTHON="${PYTHON:-python3}"
if "$PYTHON" -c 'import pdf2docx, docx' 2>/dev/null; then
"$PYTHON" "$TOOL_DIR/convert-to-docx.py" "$SOURCE_DIR" "$STAGING_DIR" "$BAND_DIR"
elif command -v docker >/dev/null 2>&1; then
PY_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/sdlc-delivery-python"
mkdir -p "$PY_CACHE"
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e PYTHONPATH=/py \
-v "$ROOT_DIR:$ROOT_DIR" -v "$BAND_DIR:$BAND_DIR:ro" -v "$PY_CACHE:/py" \
-w "$ROOT_DIR" python:3.12-slim sh -c '
python -c "import pdf2docx, docx" 2>/dev/null ||
pip install --quiet --disable-pip-version-check --target /py -r "$1"
shift; python "$@"' _ \
"$TOOL_DIR/requirements.txt" "$TOOL_DIR/convert-to-docx.py" "$SOURCE_DIR" "$STAGING_DIR" "$BAND_DIR"
else
echo "pdf2docx is not available: pip install -r $TOOL_DIR/requirements.txt, or install docker." >&2
exit 1
fi
done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z)
echo "Verifying staged delivery package..."
"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR"
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/*
* Applies the approved print layout from the first formatted Progress Status
* Record to every generated Progress Status Record HTML file. Markdown source
* files are deliberately not read or changed.
*
* Usage:
* php scripts/format_progress_status_html.php
*/
$root = dirname(__DIR__);
$directory = $root . '/sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
$templatePath = $directory . '/200-WMS-26-001-00 Progress Status Record 25690123 V1.0 ApS.html';
if (!is_file($templatePath)) {
fwrite(STDERR, "Template Progress Status Record HTML was not found.\n");
exit(1);
}
$template = file_get_contents($templatePath);
if ($template === false || !preg_match('~(/\* BRN Progress Status Record print layout.*?</style>)~s', $template, $match)) {
fwrite(STDERR, "The template does not contain the approved Progress Status Record CSS.\n");
exit(1);
}
// The CSS must work for every record number, not just record 1.
$layoutCss = str_replace(
'#progress-status-record-1-of-13',
'h1[id^="progress-status-record-"]',
$match[1]
);
/** @return string */
function approvalCard(string $id, string $title, string $paragraph): string
{
$lines = preg_split('~<br\s*/?>\s*~i', trim($paragraph)) ?: [];
$items = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
if (preg_match('~^(Signature|Date):\s*_*\s*$~i', strip_tags($line), $label)) {
$items[] = '<p class="sign-row"><strong>' . htmlspecialchars($label[1], ENT_QUOTES, 'UTF-8') . ':</strong><span class="write-line"></span></p>';
} else {
$items[] = '<p>' . $line . '</p>';
}
}
return '<div class="approval-card"><h3 id="' . $id . '">' . $title . '</h3>' . implode("\n", $items) . '</div>';
}
$files = glob($directory . '/*.html') ?: [];
$formatted = 0;
$skipped = 0;
foreach ($files as $path) {
$html = file_get_contents($path);
if ($html === false) {
fwrite(STDERR, "Could not read: $path\n");
continue;
}
if (str_contains($html, '/* BRN Progress Status Record print layout')) {
$skipped++;
continue;
}
$html = preg_replace('~</head>~', "<style>\n$layoutCss\n</style>\n</head>", $html, 1, $headCount);
if ($headCount !== 1) {
fwrite(STDERR, "Skipped malformed HTML: " . basename($path) . "\n");
continue;
}
$header = '<header class="psr-letterhead"><img class="document-header-image" src="../../../app/assets/images/brn-document-header-left.png" alt="BRN corporate identity"><div class="psr-title-band">Progress Status Record</div></header>';
$html = preg_replace(
'~(<h1 id="progress-status-record-[^"]+">.*?</h1>\s*)<table>~s',
$header . "\n" . '$1<table class="document-control">',
$html,
1,
$titleCount
);
if ($titleCount !== 1) {
fwrite(STDERR, "Skipped unsupported record structure: " . basename($path) . "\n");
continue;
}
if (preg_match('~<h2 id="8-approval">8\. Approval</h2>(.*?)(?=</body>)~s', $html, $approvalMatch)) {
preg_match_all('~<h3 id="([^"]+)">(.*?)</h3>\s*<p>(.*?)</p>~s', $approvalMatch[1], $blocks, PREG_SET_ORDER);
if (count($blocks) === 3) {
$cards = [];
foreach ($blocks as $block) {
$cards[] = approvalCard($block[1], $block[2], $block[3]);
}
$approval = '<section class="approval-page"><h2 id="8-approval">8. Approval</h2><div class="approval-grid">'
. implode("\n", $cards)
. '</div></section>';
$html = str_replace($approvalMatch[0], $approval, $html);
}
}
$date = 'Progress Status Record';
if (preg_match('~<td>Report date</td>\s*<td>(.*?)</td>~s', $html, $dateMatch)) {
$date = trim(strip_tags($dateMatch[1]));
}
$footer = '<footer class="psr-footer">200-WMS-26-001-00 · BRN WMS · Progress Status Record · '
. htmlspecialchars($date, ENT_QUOTES, 'UTF-8') . ' · V1.0 Final</footer>';
$html = str_replace('</body>', $footer . "\n</body>", $html);
if (file_put_contents($path, $html) === false) {
fwrite(STDERR, "Could not write: $path\n");
continue;
}
$formatted++;
}
printf("Formatted: %d; already formatted: %d\n", $formatted, $skipped);
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
$directory = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
$records = [
['25690123','5–23 January 2026','Project initiation','Establish the project need, stakeholders, objectives, scope, and authority.','Formal project start and scope boundary were established retrospectively from the agreed lifecycle.','Agreed reconstructed','Statement of Work; Work Schedule','Not rated — reconstructed','Historical initiation records were not created contemporaneously.','Complete customer requirements and project planning baseline.'],
['25690218','12 January–18 February 2026','Requirements and planning baseline','Collect customer requirements and define schedule, resources, risks, controls, and work-product responsibilities.','Customer Requirements, Work Schedule, and Software Project Plan were reconstructed from agreed scope and implementation evidence.','Document reconstructed','Project Plan work products','Not rated — reconstructed','Exact elicitation dates and contemporaneous approvals are unavailable.','Begin controlled software implementation on 19 February 2026.'],
['25690225','19–25 February 2026','Application initialization','Initialize the repository and establish initial application and file-upload capability.','WMS repository initialized; verification commit and dropzone/file-upload work completed.','Git evidenced','`9a50080`, `42a3876`, `8625652`, `1843308`','Green','No unresolved blocker is evidenced in the repository for this period.','Protect configuration, address security findings, and establish the stock database foundation.'],
['25690317','9–17 March 2026','Security, database, and ICS foundation','Protect local configuration, address initial security findings, design the stock database, and introduce inventory-control modules.','Configuration was removed from tracking, security-audit fixes were applied, stock database design was committed, and ICS modules were introduced.','Git evidenced','`93d903c`, `7cb78d0`, `2e558a5`, `a4f474b`','Green','The period contains a gap in commit activity before the documented security/database work.','Develop stock visibility, warehouse structure, product controls, and operational reports.'],
['25690429','9–29 April 2026','Inventory, warehouse, and access foundation','Implement stock dashboard, products, warehouse capacity, traceability, reports, settings, authentication, and reusable managers.','Dashboard, product files, OOP utilities, capacity/occupancy, lot/serial/expiry, reports, settings, login/onboarding, and manager classes were implemented.','Git evidenced','`3b8f94f` through `db5c47b`','Green','Rapid module growth increased the need for consistent authorization and lifecycle review.','Complete document approval logic, orders, warehouse layers, barcode, and role guards.'],
['25690508','30 April–8 May 2026','Orders, barcode, and security controls','Implement approval logic, customer order flows, flexible warehouse layers, barcode operations, and centralized role guards.','Password scoring, stock approval, orders/returns/invoices, switchable warehouse layers, barcode functions, role guards, security fixes, and naming/session corrections were completed.','Git evidenced','30-Apr through 08-May commits','Green','Security gaps were actively corrected during implementation; formal correction records remain to be linked.','Prepare production setup and introduce accounting capabilities.'],
['25690513','11–13 May 2026','Production preparation and accounting foundation','Prepare deployment, automate setup, correct onboarding/recovery, populate test data, and introduce accounting.','Production preparation, dynamic base URL, automated setup, password recovery, onboarding fixes, test data, dashboard updates, and accounting modules were committed.','Git evidenced','11–13 May commits','Green','Multiple fixes and a revert occurred during integration; results require traceability to tests.','Integrate accounting workflows, document control, access limits, and supporting services.'],
['25690523','20–23 May 2026','Accounting integration and supporting services','Integrate accounting, user invitation/access controls, transaction limits, document flows, Node.js, aggregates, and reports.','Accounting workflows, access controls, setup script, soft delete, Socket service, GL aggregation, accounting reports, journal batching, and GL automation were implemented.','Git evidenced','20–23 May commits','Green','Integration breadth increased regression and tenant-isolation risk.','Complete security hardening, notification control, sequencing, scheduler, tenant scoping, and final review.'],
['25690529','24–29 May 2026','Development baseline','Harden security and integrity, close known implementation gaps, stabilize services, and establish the substantially complete development baseline.','Session controls, aggregates, notifications, security/lifecycle reviews, transaction limits, document sequencing, role guards, scheduler fixes, bin naming, tenant scoping, and final refactoring were completed.','Git evidenced','24–29 May commits; final `a0677d6`','Green','Formal verification, validation, and acceptance evidence remained to be consolidated after development.','Conduct verification, validation, documentation, and delivery preparation.'],
['25690731','30 May–31 July 2026','Verification, validation, and documentation','Verify requirements/design/software, validate intended use, prepare operational documents, and consolidate delivery evidence.','This activity period is part of the agreed timeline, but contemporaneous Git evidence is unavailable; assurance and documentation records are being reconstructed from the delivered system.','Document reconstructed','SRS, Design, Traceability, Test, Guide, Verification, and Validation work products','Amber','Missing contemporaneous evidence creates an audit and acceptance gap.','Complete stabilization corrections, finalize evidence, and obtain stakeholder review.'],
['25690803','3 August 2026','Stabilization correction','Correct login and environment-configuration issues identified after the development baseline.','Login and configuration corrections were committed.','Git evidenced','`b2c4374`','Green','The correction requires linkage to the Correction Register and verification evidence.','Prepare representative demonstration data and complete final delivery checks.'],
['25690814','4–14 August 2026','Demonstration and completion boundary','Prepare controlled demonstration data, complete final checks, and reach the agreed project-completion boundary.','Demonstration data was committed on 14 August 2026 and the agreed formal completion date was reached.','Git evidenced / agreed boundary','`dd48a8b`; agreed completion date','Green','Formal signatures and some controlled ISO/IEC 29110 evidence remained open at completion.','Consolidate remaining work products and obtain Project Sponsor authorization.'],
['25690817','15–17 August 2026','Evidence consolidation and authorization preparation','Complete missing controlled work products, align roles, and prepare records for review and authorization.','PM work products were converted to reviewable Markdown, project identities and roles were aligned, and missing evidence was identified for subsequent completion.','Document reconstructed','Current `sdlc/` repository and Git status','Amber','Verification, validation, repository-backup, and acceptance evidence and signatures remain open.','Complete remaining PM/SI work products and obtain Project Sponsor review and authorization.'],
];
$tasksByDate = [
'25690123' => [
['1.1','Identify project need','09-Jan-2026','09-Jan-2026','100%','Statement of Work','Reconstructed completed'],
['1.2','Identify stakeholders and objectives','16-Jan-2026','16-Jan-2026','100%','Project role confirmation','Reconstructed completed'],
['1.3','Approve project scope','23-Jan-2026','Pending signature','90%','Statement of Work V1.0 Final','Approval pending'],
],
'25690218' => [
['2.1','Collect customer requirements','06-Feb-2026','06-Feb-2026','100%','Customer Requirements V1.0 Final','Reconstructed completed'],
['2.2','Prepare Software Project Plan','13-Feb-2026','13-Feb-2026','100%','Software Project Plan V1.0 Final','Reconstructed completed'],
['2.3','Baseline requirements and schedule','18-Feb-2026','18-Feb-2026','100%','Work Schedule and Project Plan','Reconstructed completed; approval pending'],
],
'25690225' => [
['3.1','Initialize WMS application','25-Feb-2026','25-Feb-2026','100%','Git `9a50080`–`1843308`','Completed'],
],
'25690317' => [
['3.2','Security and database foundation','17-Mar-2026','17-Mar-2026','100%','Git `93d903c`–`a4f474b`','Completed'],
],
'25690429' => [
['3.3','Inventory and warehouse modules','29-Apr-2026','29-Apr-2026','100%','Git `3b8f94f`–`db5c47b`','Completed'],
],
'25690508' => [
['3.4','Authentication and onboarding','12-May-2026','In progress at 08-May','70%','Commits through 08-May-2026','In progress; carried forward'],
['3.5','Order and barcode workflows','08-May-2026','08-May-2026','100%','02–08 May commits','Completed'],
],
'25690513' => [
['3.4','Authentication and onboarding','12-May-2026','12-May-2026','100%','Login/onboarding and recovery commits','Completed'],
['3.6','Production preparation and setup','13-May-2026','13-May-2026','100%','11–13 May commits','Completed'],
['3.7','Accounting and finance workflows','23-May-2026','In progress at 13-May','20%','Accounting foundation commits','In progress; carried forward'],
],
'25690523' => [
['3.7','Accounting and finance workflows','23-May-2026','23-May-2026','100%','13–23 May accounting commits','Completed'],
['3.8','Real-time services and scheduled jobs','27-May-2026','In progress at 23-May','45%','Node.js and GL aggregate commits','In progress; carried forward'],
['3.9','Security hardening and lifecycle review','28-May-2026','In progress at 23-May','35%','Access, flow, and role-guard commits','In progress; carried forward'],
],
'25690529' => [
['3.8','Real-time services and scheduled jobs','27-May-2026','27-May-2026','100%','22–27 May Node.js/scheduler commits','Completed'],
['3.9','Security hardening and lifecycle review','28-May-2026','28-May-2026','100%','21–28 May hardening/review commits','Completed'],
['3.10','Refactor and development baseline','29-May-2026','29-May-2026','100%','Git `a0677d6`','Completed'],
],
'25690731' => [
['4.3','Verify requirements, design, and implementation','31-Jul-2026','Evidence consolidation pending','80%','Reconstructed verification work products','In progress; evidence gap'],
['4.4','Customer-oriented system validation','07-Aug-2026','In progress at 31-Jul','85%','Reconstructed validation activities','In progress; carried forward'],
['4.5','Prepare operational documentation','07-Aug-2026','In progress at 31-Jul','85%','Configuration and draft work products','In progress; carried forward'],
],
'25690803' => [
['4.6','Configuration and login corrections','03-Aug-2026','03-Aug-2026','100%','Git `b2c4374`','Completed; formal correction closure pending'],
],
'25690814' => [
['4.4','Customer-oriented system validation','07-Aug-2026','Evidence consolidation pending','90%','Validation work products','In progress; approval pending'],
['4.5','Prepare operational documentation','07-Aug-2026','Evidence consolidation pending','90%','Operational documentation work products','In progress; approval pending'],
['4.7','Prepare demonstration data','14-Aug-2026','14-Aug-2026','100%','Git `dd48a8b`','Completed'],
['5.1','Final repository and work-product review','14-Aug-2026','In progress','70%','Repository and SDLC gap review','In progress; carried forward'],
['5.2','Acceptance and project closure','14-Aug-2026','Pending signature','75%','Completion boundary reached','Acceptance pending'],
],
'25690817' => [
['5.1','Final repository and work-product review','14-Aug-2026','In progress at 17-Aug','80%','BRN WMS PM work products and gap list','In progress'],
['5.2','Acceptance and project closure','14-Aug-2026','Pending evidence and signature','75%','Acceptance evidence not yet authorized','Acceptance pending'],
],
];
foreach ($records as $index => $record) {
[$date,$period,$milestone,$planned,$completed,$evidenceClass,$evidence,$rag,$issue,$next] = $record;
$number = $index + 1;
$taskRows = '';
foreach ($tasksByDate[$date] as $task) {
$taskRows .= '| ' . implode(' | ', $task) . " |\n";
}
$content = <<<MD
# Progress Status Record {$number} of 13
| Document field | Value |
|---|---|
| Document | Progress Status Record |
| Project | BRN WMS |
| Project code | 200-WMS-26-001-00 |
| Reporting period | {$period} |
| Report date | {$date} |
| Actual preparation date | 17 August 2026 |
| Release | {$date} V1.0 Final |
| Prepared by | คุณอภิรัชต์ สุภัทรประทีป — Project Manager |
| Technical evidence | ธนกร สถิตวิทยากุล — Developer / System Analyst |
| Status | Final — ready for review and authorization |
## 1. Retrospective-record disclosure
This record was prepared retrospectively on 17 August 2026. It covers the historical reporting period shown above and does not claim to have been authored or committed on the historical report date. Statements are limited to the evidence classification and references identified below.
## 2. Period objective
**Milestone:** {$milestone}
**Planned outcome:** {$planned}
## 3. Task progress
**Period summary:** {$completed}
| Task ID | Task | Planned finish | Actual / evidence date | Completion | Evidence | Status |
|---|---|---:|---:|---:|---|---|
{$taskRows}
| Evidence field | Value |
|---|---|
| Evidence classification | {$evidenceClass} |
| Evidence reference | {$evidence} |
| Overall period status | {$rag} |
## 4. Schedule status
Work is assessed against the agreed project boundaries of 5 January–14 August 2026. No unsupported numeric variance is asserted for this period. Where the evidence is reconstructed, schedule status remains explicitly unrated rather than represented as contemporaneously measured.
## 5. Issues, risks, and corrective action
**Issue or risk:** {$issue}
**Control:** Preserve the stated evidence basis, link applicable defects to the Correction Register, update traceability and tests, and obtain the required review or authorization without backdating records or signatures.
## 6. Changes
Technical commits in this period are implementation evidence. They must be classified separately as in-scope implementation, defect correction, or an authorized baseline change before being entered in the Change Report.
## 7. Next-period plan
{$next}
## 8. Approval
### Prepared by
Name: คุณอภิรัชต์ สุภัทรประทีป
Role: Project Manager
Signature: ______________________________________________
Date: ___________________________________________________
### Technical evidence provided by
Name: ธนกร สถิตวิทยากุล
Role: Developer / System Analyst
Signature: ______________________________________________
Date: ___________________________________________________
### Reviewed and authorized by
Name: คุณเสรี วิริยะสกุลธรณ์
Project roles: Project Sponsor / Customer Representative / Authorized Approver
Position: กรรมการผู้จัดการ
Company: บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด
Signature: ______________________________________________
Date: ___________________________________________________
MD;
$filename = "200-WMS-26-001-00 Progress Status Record {$date} V1.0 ApS.md";
if (file_put_contents($directory . '/' . $filename, $content . PHP_EOL) === false) {
throw new RuntimeException('Unable to write ' . $filename);
}
}
require __DIR__ . '/normalize_document_dates.php';
echo count($records) . " progress status records generated.\n";
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
$base = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/';
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
function e(string $s): string { return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); }
function r(string $s, bool $b = false, int $sz = 28): string {
return '<w:r><w:rPr>' . ($b ? '<w:b/><w:bCs/>' : '') . '<w:rFonts w:ascii="TH Sarabun New" w:hAnsi="TH Sarabun New" w:eastAsia="TH Sarabun New" w:cs="TH Sarabun New"/><w:sz w:val="'.$sz.'"/><w:szCs w:val="'.$sz.'"/></w:rPr><w:t xml:space="preserve">'.e($s).'</w:t></w:r>';
}
function p(string $s = '', string $a = 'left', bool $b = false, int $sz = 28, int $before = 0, int $after = 40): string {
return '<w:p><w:pPr><w:jc w:val="'.$a.'"/><w:spacing w:before="'.$before.'" w:after="'.$after.'" w:line="320" w:lineRule="auto"/></w:pPr>'.r($s,$b,$sz).'</w:p>';
}
function h(string $no, string $title): string { return p($no.' '.$title,'left',true,31,120,45); }
function b(string $s): string { return '<w:p><w:pPr><w:ind w:left="650" w:hanging="300"/><w:spacing w:after="20" w:line="310" w:lineRule="auto"/></w:pPr>'.r('• '.$s,false,28).'</w:p>'; }
function pb(): string { return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'; }
function tr(array $cells, bool $head = false, array $widths = []): string {
$x='<w:tr>';
foreach($cells as $i=>$v){$width=$widths[$i]??2200;$x.='<w:tc><w:tcPr><w:tcW w:w="'.$width.'" w:type="dxa"/>'.($head?'<w:shd w:fill="D9EAF7"/>':'').'</w:tcPr>'.p((string)$v,'left',$head,25,0,15).'</w:tc>';}
return $x.'</w:tr>';
}
function tbl(array $rows, array $widths, bool $firstHead = true): string {
$x='<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="8AA4B5"/><w:left w:val="single" w:sz="4" w:color="8AA4B5"/><w:bottom w:val="single" w:sz="4" w:color="8AA4B5"/><w:right w:val="single" w:sz="4" w:color="8AA4B5"/><w:insideH w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideV w:val="single" w:sz="4" w:color="B7C9D6"/></w:tblBorders></w:tblPr><w:tblGrid>';
foreach($widths as $wd)$x.='<w:gridCol w:w="'.$wd.'"/>';$x.='</w:tblGrid>';
foreach($rows as $i=>$row)$x.=tr($row,$firstHead&&$i===0,$widths);
return $x.'</w:tbl>';
}
function top(string $label): string {
return '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/></w:tblPr><w:tblGrid><w:gridCol w:w="6000"/><w:gridCol w:w="3000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="6000" w:type="dxa"/></w:tcPr>'.p('B.R.N. ENTERPRISE CO., LTD.','left',true,29,0,0).p('1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Bangkok 10120','left',false,18,0,0).'</w:tc><w:tc><w:tcPr><w:tcW w:w="3000" w:type="dxa"/><w:shd w:fill="0798D1"/><w:vAlign w:val="center"/></w:tcPr>'.p($label,'center',false,29,0,0).'</w:tc></w:tr></w:tbl>';
}
function meta(string $doc, string $title): string {
return tbl([
['Document No',$doc,'Release, Version','05/01/26 V1.0 Final'],
['Project Name','โครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS','Project Code','200-WMS-26-001-00'],
['Title',$title,'Project Period','5 มกราคม – 14 สิงหาคม 2569'],
['Prepared by','คุณอภิรัชต์ สุภัทรประทีป (Project Manager)','Approval','Pending authorized signature'],
],[1600,3500,1600,2300],false);
}
function saveDoc(string $path,string $title,string $body,string $code): void {
$document='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>'.$body.'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="850" w:right="1080" w:bottom="850" w:left="1080" w:header="400" w:footer="400"/><w:footerReference w:type="default" r:id="rId1"/></w:sectPr></w:body></w:document>';
$ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>';
$rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>';
$drels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>';
$footer='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'.p('ISO/IEC 29110 | 200-WMS-26-001-00 | '.$code.' | V1.0 Final','center',false,18,0,0).'</w:ftr>';
$core='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>'.e($title).'</dc:title><dc:creator>คุณอภิรัชต์ สุภัทรประทีป (Project Manager)</dc:creator><dc:description>Editable ISO/IEC 29110 controlled final document ready for authorization.</dc:description><dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created></cp:coreProperties>';
$z=new ZipArchive();if($z->open($path,ZipArchive::CREATE|ZipArchive::OVERWRITE)!==true)throw new RuntimeException('Cannot create '.$path);
foreach(['[Content_Types].xml'=>$ct,'_rels/.rels'=>$rels,'word/document.xml'=>$document,'word/_rels/document.xml.rels'=>$drels,'word/footer1.xml'=>$footer,'docProps/core.xml'=>$core] as $n=>$v)$z->addFromString($n,$v);$z->close();
}
$plan = top('Software Project Plan').p().meta('Software Project Plan','แผนการดำเนินโครงการ').h('1.','วัตถุประสงค์และภาพรวม')
.p('โครงการ BRN WMS จัดทำระบบเว็บแบบหลายบริษัทและหลายคลัง เพื่อควบคุมข้อมูลหลัก สินค้าคงคลัง เอกสารซื้อขาย การเงินและบัญชี รายงาน และการแจ้งเตือน โดยบริหารงานตามกระบวนการ Project Management และ Software Implementation ของ ISO/IEC 29110 Basic Profile.')
.h('2.','ขอบเขตและผลส่งมอบ').b('ซอฟต์แวร์ BRN WMS และฐานข้อมูลสำหรับ Authentication/Company และ WMS/Accounting').b('โมดูลผู้ใช้ บริษัท ผู้ติดต่อ สินค้า คลัง/ตำแหน่งจัดเก็บ Stock In/Out/Transfer Lot Serial Expiry และ Barcode').b('โมดูล Quotation, Order, Return, Purchase, Invoice, Receipt, Payment, General Ledger และรายงาน').b('Node.js Socket.IO สำหรับแจ้งเตือน และ Scheduler สำหรับ Stock/GL maintenance และ alerts').b('Work Products ISO/IEC 29110 จำนวน 22 รายการและ Project Repository ที่ควบคุมเวอร์ชัน')
.h('3.','แนวทางดำเนินโครงการ').tbl([['Phase','Period','Primary output'],['Initiation and planning','05-Jan–18-Feb-2026','SOW, Customer Requirements, Project Plan'],['Implementation','19-Feb–29-May-2026','Requirements, Design, Components, Tests, Software'],['Verification, validation and documentation','30-May–07-Aug-2026','Test/Verification/Validation results and guides'],['Stabilization and closure','03-Aug–14-Aug-2026','Corrections, demo data, acceptance and repository closure']],[2600,2500,3900])
.h('4.','ทีมงานและความรับผิดชอบ').tbl([['Role','Responsibility'],['Project Sponsor / Customer Representative / Authorized Approver — คุณเสรี วิริยะสกุลธรณ์','Represent customer needs and authorize scope, resources, changes and acceptance.'],['Project Manager — คุณอภิรัชต์ สุภัทรประทีป','Plan, monitor, communicate, control risks/issues and close the project.'],['Developer / System Analyst — ธนกร สถิตวิทยากุล','Analyze, design, implement, configure, correct and maintain technical records aligned with Git evidence.'],['Tester / Reviewer','Prepare and execute tests; verify work products and record results.'],['Document Control','Control identifiers, versions, approvals, repository and evidence.']],[3200,5800])
.h('5.','ทรัพยากรและสภาพแวดล้อม').b('PHP 8+, MySQL/MariaDB, Nginx or compatible web server, Node.js, npm and PM2').b('Git main branch as source baseline; configuration secrets excluded from version control').b('Two logical databases: identity/company data and WMS/accounting operational data').b('Development and deployment in Asia/Bangkok time zone with controlled configuration')
.h('6.','การบริหารความเสี่ยง').tbl([['Risk','Response / Control'],['Reconstructed pre-development evidence','Mark inferred records clearly and obtain retrospective review/approval.'],['Unauthorized access or tenant-data exposure','Role guards, company/warehouse scoping, session controls and security review.'],['Inventory inconsistency','Transactions, validation, locking, approval flow, stock summaries and reconciliation tests.'],['Configuration or secret exposure','Ignored local configuration, templates, deployment guide and restricted web access.'],['Insufficient acceptance evidence','Trace requirements to tests and obtain authorized signatures before baselining.'],['Scope expansion','Record Change Report, assess impact and authorize before implementation.']],[3600,5400])
.h('7.','คุณภาพ การทวนสอบ และการตรวจสอบความใช้ได้').b('Every approved requirement receives a unique ID and forward/backward traceability.').b('Reviews cover requirements, design, code/configuration, test records and operational documentation.').b('Critical failures prevent acceptance; other defects require disposition in the Correction Register.').b('Validation uses representative warehouse, order, finance and role-based workflows with controlled demo data.')
.h('8.','การควบคุมการเปลี่ยนแปลงและการกำหนดค่า').b('Configuration items include source code, database/setup scripts, Node.js services, controlled documents and releases.').b('Changes are requested, analyzed, approved, implemented, tested and closed with linked evidence.').b('Document filenames use project code, work-product name, Buddhist date, version and status/author indicator.')
.h('9.','การสื่อสารและการติดตาม').tbl([['Record','Frequency / trigger','Owner'],['Progress Status Record','Weekly during active work','Project Manager'],['Meeting Record','At project/review/decision meetings','Recorder'],['Correction Register','When an issue or defect is identified','Project Manager / Tester'],['Change Report','When baseline scope/configuration changes','Project Manager'],['Repository review','At baselines and closure','Document Control']],[3400,3300,2300])
.h('10.','เกณฑ์การส่งมอบและปิดโครงการ').b('Scope functions pass their linked test cases and no unresolved critical defect remains.').b('Installation, user, operation and maintenance information is available and reviewed.').b('Software and all required work products are stored in the controlled repository.').b('Verification, validation and acceptance results are reviewed and authorized.').p('Approval signature: ____________________________________','center',false,28,180,20).p('Name/position: _______________________________________','center').p('Date: ________________________________________________','center');
$reqs = [
['FR-001','Identity','Register/onboard company owner and invited users.','Must'],['FR-002','Identity','Authenticate users and apply role-based access for Owner, Admin, Staff and Viewer.','Must'],['FR-003','Identity','Support password recovery, session control and applicable OTP verification.','Must'],['FR-004','Administration','Maintain company profile, SMTP, system settings, users and application access.','Must'],
['FR-005','Master data','Maintain warehouses, storage areas/bins, product categories, products and contacts.','Must'],['FR-006','Master data','Support simple and layered warehouse-location models.','Should'],['FR-007','Inventory','Record stock-in with product, quantity, warehouse/bin and traceability attributes.','Must'],['FR-008','Inventory','Record stock-out with authorization and balance validation.','Must'],['FR-009','Inventory','Transfer stock between controlled warehouse locations.','Must'],['FR-010','Inventory','Track lot, serial number and expiry-date information where applicable.','Must'],['FR-011','Inventory','Display stock overview, movement history, capacity/occupancy and low-stock information.','Must'],['FR-012','Inventory','Generate SKU and location barcode labels and support scanning workflows.','Should'],
['FR-013','Sales','Create and manage quotations, sales orders, invoices, returns and credit notes.','Must'],['FR-014','Purchasing','Create and manage purchase requests, purchase orders, supplier invoices and supplier returns.','Must'],['FR-015','Finance','Create and manage receipt/payment billing, receipts and payments.','Must'],['FR-016','Accounting','Maintain chart of accounts, departments, formulas, journals and GL entries.','Must'],['FR-017','Accounting','Produce trial balance, profit/loss, balance sheet, VAT and GL movement reports.','Must'],['FR-018','Documents','Generate controlled document numbers and manage document lifecycle/status.','Must'],['FR-019','Documents','Attach permitted files to supported records.','Should'],['FR-020','Reports','Filter, view and export operational and management reports.','Must'],
['FR-021','Notifications','Notify authorized users of relevant status transitions and operational alerts.','Should'],['FR-022','Scheduler','Maintain stock/GL summaries and produce low-stock/overdue-invoice alerts.','Should'],['FR-023','Audit','Preserve creator/updater/status and transaction history required for review.','Must'],['FR-024','Isolation','Restrict company and warehouse data to the authorized user context.','Must'],
['NFR-001','Security','Protect secrets from source control and direct web access.','Must'],['NFR-002','Security','Validate input, authorize server-side actions and prevent cross-tenant access.','Must'],['NFR-003','Integrity','Execute related database updates transactionally and prevent invalid negative/duplicate movements.','Must'],['NFR-004','Availability','Provide documented installation, configuration, backup and recovery procedures.','Must'],['NFR-005','Usability','Provide responsive browser UI suitable for desktop and warehouse-floor devices.','Should'],['NFR-006','Performance','Provide practical response times for daily operations and use aggregate tables for dashboards/reports.','Should'],['NFR-007','Maintainability','Use modular managers/APIs, centralized helpers, configuration templates and version control.','Should'],['NFR-008','Compatibility','Run on PHP 8+, MySQL/MariaDB and current standards-based browsers.','Must'],['NFR-009','Traceability','Link each approved requirement to design, component and verification evidence.','Must'],['NFR-010','Time','Use Asia/Bangkok consistently for application and scheduled jobs.','Must'],
];
$cr = top('Customer Requirements').p().meta('Customer Requirements','เอกสารบันทึกและสรุปความต้องการของลูกค้า').h('1.','วัตถุประสงค์').p('เอกสารฉบับนี้กำหนดความต้องการระดับลูกค้าและผู้ใช้ของ BRN WMS เพื่อเป็นฐานสำหรับ Software Requirements Specification, Software Design, Traceability Record, Test Cases และ Acceptance. ข้อมูลถูกรวบรวมย้อนหลังจากขอบเขตที่ตกลง ซอร์สโค้ด ฐานข้อมูล และประวัติ Git โดยธนกร สถิตวิทยากุล ในบทบาท Developer / System Analyst และต้องได้รับการทบทวนและอนุมัติโดยคุณเสรี วิริยะสกุลธรณ์ ในบทบาท Project Sponsor / Customer Representative / Authorized Approver ก่อน Baseline.')
.h('2.','ผู้มีส่วนได้ส่วนเสีย').tbl([['Stakeholder','Interest / role'],['Authorized Sponsor','Approve scope, resources, changes and final acceptance.'],['Warehouse Manager / Staff','Accurate receiving, issuing, transfer, stock status, traceability and reports.'],['Sales and Purchasing Users','Controlled quotation/order/purchase/invoice/return workflows.'],['Finance and Accounting Users','Billing, receipts, payments, journals, GL and financial reports.'],['System Administrator','Company configuration, user access, deployment, monitoring and recovery.'],['Auditor / Management','Traceable transactions, controlled records and management information.']],[3400,5600])
.h('3.','สมมติฐานและข้อจำกัด').b('The solution is a browser-based application deployed on a controlled PHP/MySQL environment.').b('External ERP integration, legacy-data migration, hardware procurement and custom third-party services are outside scope unless changed formally.').b('Customer representatives provide representative data, review requirements and participate in validation and acceptance.').b('Requirements marked Must are acceptance-critical; Should requirements may be accepted with documented disposition.')
.h('4.','รายการความต้องการ');
$reqRows=[['ID','Area','Requirement','Priority']];foreach($reqs as $q)$reqRows[]=$q;$cr.=tbl($reqRows,[1300,1700,5100,900]).h('5.','เกณฑ์การยอมรับความต้องการ').b('Each Must requirement is implemented, traced to one or more test cases and passes verification/validation.').b('No critical security, tenant-isolation, inventory-integrity or transaction defect remains open.').b('Any exception or deferred Should requirement is recorded and authorized.').b('Customer review confirms that representative end-to-end workflows support intended warehouse operations.').h('6.','การอนุมัติ Baseline').p('Customer representative signature: ______________________________','center',false,28,180,20).p('Name/position: ______________________________________________','center').p('Date: ________________________________________________________','center');
saveDoc($base.'2-Software Project Plan/200-WMS-26-001-00 Software Project Plan 25690105 V1.0 Final.docx','200-WMS-26-001-00 Software Project Plan',$plan,'PP');
saveDoc($base.'3-Customer Requirement/200-WMS-26-001-00 Customer Requirements 25690112 V1.0 Final.docx','200-WMS-26-001-00 Customer Requirements',$cr,'CR');
echo "Project Plan documents generated.\n";
require __DIR__ . '/normalize_document_dates.php';
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
/**
* Generate the ISO/IEC 29110 Statement of Work for BRN WMS.
*
* The document is generated as native OOXML so it remains editable in Word.
*/
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/1.Statement of Work/'
. '200-WMS-26-001-00 Statement of Work 25690105 V1.0 Final.docx';
function xml(string $value): string
{
return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
function run(string $text, bool $bold = false, int $size = 30): string
{
$boldXml = $bold ? '<w:b/><w:bCs/>' : '';
return '<w:r><w:rPr>' . $boldXml
. '<w:rFonts w:ascii="TH Sarabun New" w:hAnsi="TH Sarabun New" w:eastAsia="TH Sarabun New" w:cs="TH Sarabun New"/>'
. '<w:sz w:val="' . $size . '"/><w:szCs w:val="' . $size . '"/>'
. '<w:lang w:val="th-TH" w:eastAsia="th-TH" w:bidi="th-TH"/>'
. '</w:rPr><w:t xml:space="preserve">' . xml($text) . '</w:t></w:r>';
}
function para(string $text = '', string $align = 'left', bool $bold = false, int $size = 30, int $before = 0, int $after = 80, int $indent = 0): string
{
return '<w:p><w:pPr><w:jc w:val="' . $align . '"/>'
. '<w:spacing w:before="' . $before . '" w:after="' . $after . '" w:line="360" w:lineRule="auto"/>'
. ($indent > 0 ? '<w:ind w:firstLine="' . $indent . '"/>' : '')
. '</w:pPr>' . run($text, $bold, $size) . '</w:p>';
}
function bullet(string $text): string
{
return '<w:p><w:pPr><w:ind w:left="720" w:hanging="360"/><w:spacing w:after="50" w:line="340" w:lineRule="auto"/></w:pPr>'
. run('• ' . $text, false, 29) . '</w:p>';
}
function tableRow(string $label, string $value): string
{
return '<w:tr>'
. '<w:tc><w:tcPr><w:tcW w:w="2200" w:type="dxa"/><w:shd w:fill="D9EAF7"/></w:tcPr>'
. para($label, 'left', true, 27, 0, 20) . '</w:tc>'
. '<w:tc><w:tcPr><w:tcW w:w="6800" w:type="dxa"/></w:tcPr>'
. para($value, 'left', false, 27, 0, 20) . '</w:tc>'
. '</w:tr>';
}
$header = '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblLayout w:type="fixed"/></w:tblPr><w:tblGrid><w:gridCol w:w="6100"/><w:gridCol w:w="2900"/></w:tblGrid>'
. '<w:tr><w:tc><w:tcPr><w:tcW w:w="6100" w:type="dxa"/></w:tcPr>'
. para('B.R.N. ENTERPRISE CO., LTD.', 'left', true, 29, 0, 0)
. para('1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road,', 'left', false, 19, 0, 0)
. para('Chongnonsi, Yannawa, Bangkok 10120', 'left', false, 19, 0, 0)
. '</w:tc><w:tc><w:tcPr><w:tcW w:w="2900" w:type="dxa"/><w:shd w:fill="0798D1"/><w:vAlign w:val="center"/></w:tcPr>'
. para('Statement of Work', 'center', false, 29, 0, 0)
. '</w:tc></w:tr></w:tbl>';
$meta = '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblBorders>'
. '<w:top w:val="single" w:sz="4" w:color="9E9E9E"/><w:left w:val="single" w:sz="4" w:color="9E9E9E"/>'
. '<w:bottom w:val="single" w:sz="4" w:color="9E9E9E"/><w:right w:val="single" w:sz="4" w:color="9E9E9E"/>'
. '<w:insideH w:val="single" w:sz="4" w:color="9E9E9E"/><w:insideV w:val="single" w:sz="4" w:color="9E9E9E"/>'
. '</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2200"/><w:gridCol w:w="6800"/></w:tblGrid>'
. tableRow('รหัสโครงการ', '200-WMS-26-001-00')
. tableRow('ชื่อโครงการ', 'โครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS')
. tableRow('ระยะเวลาโครงการ', '5 มกราคม 2569 – 14 สิงหาคม 2569')
. tableRow('เอกสาร', 'Statement of Work · Version 1.0 · สถานะ Final')
. '</w:tbl>';
$body = $header
. para('', 'left', false, 10, 0, 80)
. para('วันที่ 5 มกราคม 2569', 'center', false, 30, 80, 180)
. para('เรื่อง ขอบเขตการดำเนินงานโครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS', 'left', true, 30, 0, 100)
. para('เรียน ผู้บริหารและผู้มีส่วนเกี่ยวข้องในโครงการ', 'left', true, 30, 0, 180)
. para('บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด มีความประสงค์ดำเนินโครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS เพื่อเพิ่มความถูกต้องและความรวดเร็วของงานคลังสินค้า ทำให้สามารถติดตามสินค้าคงคลัง การเคลื่อนไหวสินค้า คำสั่งซื้อ เอกสารทางธุรกิจ และข้อมูลบัญชีที่เกี่ยวข้องได้อย่างเป็นระบบ โดยใช้ข้อมูลแบบรวมศูนย์และกำหนดสิทธิ์การเข้าถึงตามบทบาทผู้ใช้งาน', 'both', false, 30, 0, 120, 720)
. para('เอกสารฉบับนี้กำหนดขอบเขต ผลส่งมอบ เกณฑ์การยอมรับ หน้าที่ความรับผิดชอบ และกรอบระยะเวลาของโครงการตามแนวทาง ISO/IEC 29110 โดยมีรายละเอียดดังต่อไปนี้', 'both', false, 30, 0, 180, 720)
. $meta
. para('1. วัตถุประสงค์', 'left', true, 32, 180, 80)
. bullet('พัฒนาระบบเว็บสำหรับควบคุมสินค้าคงคลังและการปฏิบัติงานคลังสินค้าแบบหลายคลัง')
. bullet('สนับสนุนการรับเข้า เบิกจ่าย โอนย้าย ปรับปรุง และตรวจสอบยอดคงเหลือ พร้อมการติดตาม Lot, Serial Number และวันหมดอายุ')
. bullet('ลดความผิดพลาดจากการทำงานด้วยมือและเพิ่มความสามารถในการตรวจสอบย้อนหลัง')
. bullet('สนับสนุนการบริหารคำสั่งซื้อ จัดซื้อ คืนสินค้า ใบแจ้งหนี้ รายงาน และรายการบัญชีที่เกี่ยวข้อง')
. bullet('จัดให้มีการรักษาความมั่นคงปลอดภัย การแยกข้อมูลรายบริษัท การกำหนดสิทธิ์ และการแจ้งเตือนแบบเรียลไทม์')
. para('2. ขอบเขตงาน', 'left', true, 32, 150, 80)
. para('2.1 งานวิเคราะห์และออกแบบ', 'left', true, 29, 0, 50)
. bullet('รวบรวมและวิเคราะห์ความต้องการของผู้ใช้ กำหนดกระบวนการทำงาน ข้อมูล และกฎทางธุรกิจ')
. bullet('ออกแบบสถาปัตยกรรมระบบ ฐานข้อมูล ส่วนติดต่อผู้ใช้ การเชื่อมต่อบริการ และมาตรการความมั่นคงปลอดภัย')
. para('2.2 งานพัฒนาระบบ', 'left', true, 29, 80, 50)
. bullet('ข้อมูลหลัก: บริษัท ผู้ใช้ ผู้ติดต่อ สินค้า คลังสินค้า โซน ทางเดิน ตำแหน่งจัดเก็บ และหน่วยนับ')
. bullet('สินค้าคงคลัง: รับเข้า จ่ายออก โอนย้าย ปรับปรุงยอด ตรวจนับ ยอดคงเหลือ และประวัติการเคลื่อนไหว')
. bullet('เอกสารธุรกิจ: Sales Order, Purchase Order, Return, Invoice และลำดับเลขที่เอกสาร')
. bullet('การเงินและบัญชี: รายรับ รายจ่าย สมุดรายวัน รายการบัญชี และรายงานที่ระบบรองรับ')
. bullet('Dashboard, รายงาน, การส่งออกข้อมูล, Barcode/Label และการแนบไฟล์')
. bullet('การยืนยันตัวตน การกำหนดบทบาท Owner/Admin/Staff/Viewer การจำกัดข้อมูลตามบริษัท และการควบคุม session')
. bullet('บริการแจ้งเตือนแบบเรียลไทม์และงานตามกำหนดเวลาด้วย Node.js/Socket.IO')
. para('2.3 งานทดสอบและส่งมอบ', 'left', true, 29, 80, 50)
. bullet('จัดทำและดำเนินการทดสอบตามความต้องการ บันทึกผล แก้ไขข้อบกพร่อง และทดสอบยืนยันผล')
. bullet('จัดเตรียมคู่มือผู้ใช้ คู่มือการติดตั้ง/ปฏิบัติการ และเอกสารบำรุงรักษา')
. bullet('จัดเตรียมหลักฐานการทวนสอบ การตรวจสอบความใช้ได้ และการยอมรับระบบ')
. para('3. ผลส่งมอบ', 'left', true, 32, 150, 80)
. para('ผลส่งมอบประกอบด้วยซอฟต์แวร์ BRN WMS ซอร์สโค้ด สคริปต์การติดตั้งและฐานข้อมูล การกำหนดค่า คู่มือ และ Work Products ของกระบวนการ Project Management และ Software Implementation ตาม ISO/IEC 29110 รวม 22 รายการ โดยจัดเก็บใน Project Repository ภายใต้การควบคุมเวอร์ชัน', 'both', false, 30, 0, 100, 720)
. para('4. ข้อยกเว้นและข้อสมมติ', 'left', true, 32, 120, 80)
. bullet('ไม่รวมการจัดหาเครื่องแม่ข่าย อุปกรณ์เครือข่าย เครื่องสแกน Barcode หรือบริการจากบุคคลภายนอก เว้นแต่ได้รับอนุมัติเพิ่มเติม')
. bullet('การย้ายข้อมูลเดิม การเชื่อมต่อ ERP/บริการภายนอก และการปรับแต่งนอกขอบเขตต้องผ่านกระบวนการ Change Request')
. bullet('ผู้มีส่วนเกี่ยวข้องต้องให้ข้อมูล ทบทวนเอกสาร และเข้าร่วมการทดสอบ/ยอมรับตามกำหนด')
. para('5. แผนงานและจุดควบคุม', 'left', true, 32, 150, 80)
. bullet('เริ่มต้นและวางแผนโครงการ: 5 มกราคม – 18 กุมภาพันธ์ 2569')
. bullet('พัฒนาและทดสอบภายใน: 19 กุมภาพันธ์ – 29 พฤษภาคม 2569')
. bullet('ทดสอบการยอมรับ จัดทำเอกสาร ส่งมอบ และปรับเสถียรภาพ: 30 พฤษภาคม – 14 สิงหาคม 2569')
. bullet('วันเสร็จสิ้นโครงการอย่างเป็นทางการ: 14 สิงหาคม 2569')
. para('6. เกณฑ์การยอมรับ', 'left', true, 32, 150, 80)
. bullet('ฟังก์ชันที่อยู่ในขอบเขตผ่าน Test Cases และเชื่อมโยงกับความต้องการใน Traceability Record')
. bullet('ข้อบกพร่องระดับร้ายแรงที่ขัดขวางการใช้งานได้รับการแก้ไขหรือมีแนวทางที่ผู้มีอำนาจยอมรับ')
. bullet('เอกสารการติดตั้ง การใช้งาน การปฏิบัติการ และการบำรุงรักษาพร้อมใช้งาน')
. bullet('ผลการ Verification, Validation และ Acceptance ได้รับการทบทวนและลงนามโดยผู้มีอำนาจ')
. para('7. การบริหารการเปลี่ยนแปลง', 'left', true, 32, 150, 80)
. para('การเปลี่ยนแปลงขอบเขต กำหนดการ หรือผลส่งมอบต้องบันทึกใน Change Report ประเมินผลกระทบ และได้รับอนุมัติก่อนดำเนินการ การแก้ไขข้อบกพร่องให้บันทึกใน Correction Register และเชื่อมโยงกับหลักฐานการทดสอบที่เกี่ยวข้อง', 'both', false, 30, 0, 120, 720)
. para('จึงจัดทำ Statement of Work ฉบับนี้เพื่อใช้เป็นกรอบดำเนินงานและขอให้ผู้เกี่ยวข้องพิจารณาอนุมัติตามอำนาจหน้าที่', 'both', false, 30, 80, 240, 720)
. para('ลงชื่อ ____________________________________', 'center', false, 30, 240, 20)
. para('(คุณเสรี วิริยะสกุลธรณ์)', 'center', false, 30, 0, 20)
. para('กรรมการผู้จัดการ · Project Sponsor / Customer Representative / Authorized Approver', 'center', false, 26, 0, 20)
. para('บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด', 'center', false, 30, 0, 20)
. para('วันที่ ____________________________________', 'center', false, 30, 0, 80);
$document = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<w:body>' . $body
. '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1080" w:right="1080" w:bottom="1080" w:left="1260" w:header="500" w:footer="500"/>'
. '<w:footerReference w:type="default" r:id="rId1"/></w:sectPr></w:body></w:document>';
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
. '<Default Extension="xml" ContentType="application/xml"/>'
. '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
. '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
. '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
. '</Types>';
$rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
. '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
. '</Relationships>';
$documentRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>'
. '</Relationships>';
$footer = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
. '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>'
. run('200-WMS-26-001-00 · Statement of Work · V1.0 · Final', false, 18)
. '</w:p></w:ftr>';
$core = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
. 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
. '<dc:title>200-WMS-26-001-00 Statement of Work</dc:title><dc:subject>ISO/IEC 29110 Project Management Work Product</dc:subject>'
. '<dc:creator>BRN WMS Project Team</dc:creator><cp:keywords>ISO/IEC 29110; Statement of Work; BRN WMS</cp:keywords>'
. '<dc:description>Editable final document ready for authorized approval and signature.</dc:description>'
. '<dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created>'
. '<dcterms:modified xsi:type="dcterms:W3CDTF">2026-08-17T00:00:00+07:00</dcterms:modified>'
. '</cp:coreProperties>';
$app = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
. 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
. '<Application>Microsoft Office Word</Application><Company>B.R.N. Enterprise Co., Ltd.</Company><AppVersion>16.0000</AppVersion>'
. '</Properties>';
$zip = new ZipArchive();
if ($zip->open($output, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Unable to create ' . $output);
}
$zip->addFromString('[Content_Types].xml', $contentTypes);
$zip->addFromString('_rels/.rels', $rels);
$zip->addFromString('word/document.xml', $document);
$zip->addFromString('word/_rels/document.xml.rels', $documentRels);
$zip->addFromString('word/footer1.xml', $footer);
$zip->addFromString('docProps/core.xml', $core);
$zip->addFromString('docProps/app.xml', $app);
$zip->close();
echo $output . PHP_EOL;
require __DIR__ . '/normalize_document_dates.php';
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/1-Work Schedule/'
. '200-WMS-26-001-00 Work Schedule 25690105 V1.0 Final.xlsx';
$headers = ['No.', 'Phase', 'Task', 'Details / Basis', 'Responsible Role', 'Start', 'Finish', 'Duration (days)', 'Deliverable / Evidence', 'Status', 'Remarks'];
$rows = [
['1.1', 'Initiation', 'Identify project need', 'Establish need for centralized warehouse, inventory, order, and accounting control.', 'Project Sponsor / Project Manager', '05-Jan-2026', '09-Jan-2026', '5', 'Statement of Work', 'Completed', 'Reconstructed planning activity'],
['1.2', 'Initiation', 'Identify stakeholders and objectives', 'Identify sponsor, operational users, system administrator, development, and approval roles.', 'Project Manager', '05-Jan-2026', '16-Jan-2026', '10', 'Stakeholder and objective records', 'Completed', 'Reconstructed planning activity'],
['1.3', 'Initiation', 'Approve project scope', 'Confirm project boundaries, assumptions, deliverables, and acceptance approach.', 'Project Sponsor', '19-Jan-2026', '23-Jan-2026', '5', 'Approved Statement of Work', 'Pending signature', 'Authority signature required'],
['2.1', 'Planning', 'Collect customer requirements', 'Document functional, data, security, operational, and quality requirements.', 'System Analyst / Customer Representatives', '12-Jan-2026', '06-Feb-2026', '20', 'Customer Requirements', 'Completed', 'Reconstructed from implemented system'],
['2.2', 'Planning', 'Prepare Software Project Plan', 'Define lifecycle, resources, risks, repository, configuration, communication, and controls.', 'Project Manager', '26-Jan-2026', '13-Feb-2026', '15', 'Software Project Plan', 'Completed', 'Reconstructed project plan'],
['2.3', 'Planning', 'Baseline requirements and schedule', 'Review initial requirements, priorities, milestones, and work-product responsibilities.', 'Project Manager / System Analyst', '16-Feb-2026', '18-Feb-2026', '3', 'Baseline plan and requirements', 'Completed', 'Development begins 19-Feb-2026'],
['3.1', 'Execution', 'Initialize WMS application', 'Create initial PHP application repository and baseline structure.', 'System Analyst / Developer', '19-Feb-2026', '25-Feb-2026', '5', 'Git commits 9a50080–1843308', 'Completed', 'Git evidenced'],
['3.2', 'Execution', 'Security and database foundation', 'Configuration protection, security audit actions, stock database design.', 'Developer', '09-Mar-2026', '17-Mar-2026', '7', 'Git commits 93d903c–a4f474b', 'Completed', 'Git evidenced'],
['3.3', 'Execution', 'Inventory and warehouse modules', 'Warehouse capacity, product, storage/bin, lot, serial, expiry, stock movements, and reports.', 'Developer', '09-Apr-2026', '29-Apr-2026', '15', 'Inventory, ICS, dashboard and report modules', 'Completed', 'Git evidenced'],
['3.4', 'Execution', 'Authentication and onboarding', 'Login, registration, onboarding, password controls, and role-based access.', 'Developer', '28-Apr-2026', '12-May-2026', '11', 'Authentication and user-management modules', 'Completed', 'Git evidenced'],
['3.5', 'Execution', 'Order and barcode workflows', 'Orders, returns, invoices, switchable warehouse layers, barcode labels, and scanning.', 'Developer', '02-May-2026', '08-May-2026', '5', 'Order and barcode modules', 'Completed', 'Git evidenced'],
['3.6', 'Execution', 'Production preparation and setup', 'Dynamic base URL, automated database setup, recovery flow, and production preparation.', 'Developer', '11-May-2026', '13-May-2026', '3', 'Setup and configuration implementation', 'Completed', 'Git evidenced'],
['3.7', 'Execution', 'Accounting and finance workflows', 'Chart of accounts, GL, journals, reports, billing, payment and receipt workflows.', 'Developer', '13-May-2026', '23-May-2026', '9', 'Accounting and finance modules', 'Completed', 'Git evidenced'],
['3.8', 'Execution', 'Real-time services and scheduled jobs', 'Socket notifications, stock/GL aggregates, low-stock and overdue-invoice schedules.', 'Developer', '22-May-2026', '27-May-2026', '4', 'Node.js service and cron jobs', 'Completed', 'Git evidenced'],
['3.9', 'Execution', 'Security hardening and lifecycle review', 'Role guards, tenant scoping, document flows, transaction limits, and correction closure.', 'Developer / Reviewer', '21-May-2026', '28-May-2026', '6', 'Security and lifecycle review commits', 'Completed', 'Git evidenced'],
['3.10', 'Execution', 'Refactor and development baseline', 'Remove redundancy and establish substantially complete development baseline.', 'Developer', '29-May-2026', '29-May-2026', '1', 'Git commit a0677d6', 'Completed', 'Development substantially complete'],
['4.1', 'Control', 'Maintain progress and meeting records', 'Track progress, issues, decisions, risks, and corrective actions throughout the project.', 'Project Manager / Document Control', '05-Jan-2026', '14-Aug-2026', '160', 'Progress Status, Meeting Records, Correction Register', 'Completed', 'Records require evidence review'],
['4.2', 'Control', 'Configuration and repository control', 'Control source, baselines, document versions, configuration, and backups.', 'Configuration Manager', '19-Feb-2026', '14-Aug-2026', '127', 'Git repository and Software Configuration', 'Completed', 'Git repository evidenced'],
['4.3', 'Verification', 'Verify requirements, design, and implementation', 'Conduct reviews and tests; link requirements, design, code, and test evidence.', 'Reviewer / Tester', '30-May-2026', '31-Jul-2026', '45', 'Verification Results and Traceability Record', 'Completed', 'Formal approval pending'],
['4.4', 'Validation', 'Customer-oriented system validation', 'Validate operational workflows and quality attributes against intended use.', 'Customer Representatives / Tester', '01-Jun-2026', '07-Aug-2026', '50', 'Validation Results and Test Report', 'Completed', 'Formal approval pending'],
['4.5', 'Documentation', 'Prepare operational documentation', 'Prepare user, operation, configuration, deployment, and maintenance guidance.', 'Developer / Document Control', '01-Jun-2026', '07-Aug-2026', '50', 'User Documentation, Operation Guide, Maintenance Documentation', 'Completed', 'Reconstructed documentation period'],
['4.6', 'Stabilization', 'Configuration and login corrections', 'Correct login and environment configuration issues identified after baseline.', 'Developer', '03-Aug-2026', '03-Aug-2026', '1', 'Git commit b2c4374', 'Completed', 'Git evidenced'],
['4.7', 'Stabilization', 'Prepare demonstration data', 'Populate controlled demonstration data for validation and handover support.', 'Developer / Tester', '14-Aug-2026', '14-Aug-2026', '1', 'Git commit dd48a8b', 'Completed', 'Git evidenced'],
['5.1', 'Closure', 'Final repository and work-product review', 'Confirm required work products, traceability, configuration items, and unresolved actions.', 'Project Manager / Document Control', '10-Aug-2026', '14-Aug-2026', '5', 'Repository review and List of Evidence', 'Completed', 'Approval records pending'],
['5.2', 'Closure', 'Acceptance and project closure', 'Obtain authorized acceptance and record project closure and follow-up actions.', 'Project Sponsor / Project Manager', '14-Aug-2026', '14-Aug-2026', '1', 'Acceptance Report and closure record', 'Pending signature', 'Authority signature required'],
];
function esc(string $value): string
{
return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
function colName(int $number): string
{
$name = '';
while ($number > 0) {
$number--;
$name = chr(65 + ($number % 26)) . $name;
$number = intdiv($number, 26);
}
return $name;
}
function cell(string $reference, string $value, int $style): string
{
return '<c r="' . $reference . '" t="inlineStr" s="' . $style . '"><is><t xml:space="preserve">' . esc($value) . '</t></is></c>';
}
$sheetRows = [];
$sheetRows[] = '<row r="1" ht="30" customHeight="1">'
. cell('A1', 'Document No: Work Schedule', 1)
. cell('B1', 'Project: BRN WMS', 1)
. cell('C1', 'Project Code: 200-WMS-26-001-00', 1)
. cell('D1', 'Period: 05-Jan-2026 to 14-Aug-2026', 1)
. cell('E1', 'Release: 05/01/26 V1.0 Final', 1)
. '</row>';
$sheetRows[] = '<row r="2" ht="26" customHeight="1">';
foreach ($headers as $index => $header) {
$sheetRows[1] .= cell(colName($index + 1) . '2', $header, 2);
}
$sheetRows[1] .= '</row>';
foreach ($rows as $rowIndex => $row) {
$excelRow = $rowIndex + 3;
$xml = '<row r="' . $excelRow . '" ht="42" customHeight="1">';
foreach ($row as $columnIndex => $value) {
$style = $columnIndex === 9
? ($value === 'Completed' ? 4 : 5)
: ($columnIndex === 0 || $columnIndex >= 5 && $columnIndex <= 7 ? 3 : 6);
$xml .= cell(colName($columnIndex + 1) . $excelRow, $value, $style);
}
$sheetRows[] = $xml . '</row>';
}
$lastRow = count($rows) + 2;
$sheet = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr><dimension ref="A1:K' . $lastRow . '"/>'
. '<sheetViews><sheetView workbookViewId="0"><pane ySplit="2" topLeftCell="A3" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
. '<cols><col min="1" max="1" width="7" customWidth="1"/><col min="2" max="2" width="14" customWidth="1"/>'
. '<col min="3" max="3" width="29" customWidth="1"/><col min="4" max="4" width="48" customWidth="1"/>'
. '<col min="5" max="5" width="28" customWidth="1"/><col min="6" max="7" width="14" customWidth="1"/>'
. '<col min="8" max="8" width="14" customWidth="1"/><col min="9" max="9" width="34" customWidth="1"/>'
. '<col min="10" max="10" width="18" customWidth="1"/><col min="11" max="11" width="28" customWidth="1"/></cols>'
. '<sheetData>' . implode('', $sheetRows) . '</sheetData>'
. '<autoFilter ref="A2:K' . $lastRow . '"/><mergeCells count="5"><mergeCell ref="A1:A1"/><mergeCell ref="B1:B1"/><mergeCell ref="C1:C1"/><mergeCell ref="D1:D1"/><mergeCell ref="E1:K1"/></mergeCells>'
. '<pageMargins left="0.25" right="0.25" top="0.4" bottom="0.4" header="0.2" footer="0.2"/>'
. '<pageSetup orientation="landscape" paperSize="9" fitToWidth="1" fitToHeight="0"/>'
. '<printOptions horizontalCentered="1"/><headerFooter><oddFooter>&amp;LISO/IEC 29110&amp;C200-WMS-26-001-00&amp;RPage &amp;P of &amp;N</oddFooter></headerFooter>'
. '</worksheet>';
$styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
. '<fonts count="4"><font><sz val="10"/><name val="Arial"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="11"/><name val="Arial"/></font>'
. '<font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/></font><font><sz val="9"/><name val="Arial"/></font></fonts>'
. '<fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>'
. '<fill><patternFill patternType="solid"><fgColor rgb="FF075985"/></patternFill></fill>'
. '<fill><patternFill patternType="solid"><fgColor rgb="FF0E7490"/></patternFill></fill>'
. '<fill><patternFill patternType="solid"><fgColor rgb="FFFEF3C7"/></patternFill></fill></fills>'
. '<borders count="2"><border/><border><left style="thin"><color rgb="FFB7C9D6"/></left><right style="thin"><color rgb="FFB7C9D6"/></right><top style="thin"><color rgb="FFB7C9D6"/></top><bottom style="thin"><color rgb="FFB7C9D6"/></bottom></border></borders>'
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
. '<cellXfs count="7"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
. '<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>'
. '<xf numFmtId="0" fontId="2" fillId="2" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf>'
. '<xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
. '<xf numFmtId="0" fontId="3" fillId="3" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
. '<xf numFmtId="0" fontId="3" fillId="4" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
. '<xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs>'
. '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>'
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
. '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>';
$rootRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
. '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>';
$workbook = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<sheets><sheet name="Work Schedule" sheetId="1" r:id="rId1"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">\'Work Schedule\'!$A$1:$K$' . $lastRow . '</definedName></definedNames></workbook>';
$workbookRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
$core = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>200-WMS-26-001-00 Work Schedule</dc:title><dc:creator>BRN WMS Project Team</dc:creator><dc:description>ISO/IEC 29110 reconstructed final project schedule ready for authorization.</dc:description><dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created></cp:coreProperties>';
$app = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Microsoft Excel</Application><Company>B.R.N. Enterprise Co., Ltd.</Company></Properties>';
$zip = new ZipArchive();
if ($zip->open($output, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Cannot create ' . $output);
}
foreach (['[Content_Types].xml' => $contentTypes, '_rels/.rels' => $rootRels, 'xl/workbook.xml' => $workbook, 'xl/_rels/workbook.xml.rels' => $workbookRels, 'xl/worksheets/sheet1.xml' => $sheet, 'xl/styles.xml' => $styles, 'docProps/core.xml' => $core, 'docProps/app.xml' => $app] as $name => $contents) {
$zip->addFromString($name, $contents);
}
$zip->close();
echo $output . PHP_EOL;
require __DIR__ . '/normalize_document_dates.php';
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
$root = __DIR__ . '/../sdlc';
$english = [
'January'=>'01','February'=>'02','March'=>'03','April'=>'04','May'=>'05','June'=>'06',
'July'=>'07','August'=>'08','September'=>'09','October'=>'10','November'=>'11','December'=>'12',
'Jan'=>'01','Feb'=>'02','Mar'=>'03','Apr'=>'04','Jun'=>'06','Jul'=>'07','Aug'=>'08','Sep'=>'09','Oct'=>'10','Nov'=>'11','Dec'=>'12',
];
$thai = ['มกราคม'=>'01','กุมภาพันธ์'=>'02','มีนาคม'=>'03','เมษายน'=>'04','พฤษภาคม'=>'05','มิถุนายน'=>'06','กรกฎาคม'=>'07','สิงหาคม'=>'08','กันยายน'=>'09','ตุลาคม'=>'10','พฤศจิกายน'=>'11','ธันวาคม'=>'12'];
function normalDate(int|string $day, string $month, int|string $year, array $english, array $thai): string
{
$monthNo = $english[$month] ?? $thai[$month] ?? null;
if ($monthNo === null) return (string)$day . ' ' . $month . ' ' . (string)$year;
$yearNo = (int)$year;
if ($yearNo >= 2400) $yearNo -= 543;
return sprintf('%02d/%s/%02d', (int)$day, $monthNo, $yearNo % 100);
}
function normalizeDates(string $text, array $english, array $thai): string
{
$months = implode('|', array_map(static fn($v) => preg_quote($v, '/'), array_keys($english + $thai)));
// Compact Buddhist document/report dates, e.g. 25690817.
$text = preg_replace_callback('/\b(25\d{2})(\d{2})(\d{2})\b/', static function ($m) {
return sprintf('%02d/%02d/%02d', (int)$m[3], (int)$m[2], ((int)$m[1] - 543) % 100);
}, $text) ?? $text;
// Shared-month range: 11–13 May 2026.
$text = preg_replace_callback('/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\s+('.$months.')\s+(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[3],$m[4],$english,$thai).'–'.normalDate($m[2],$m[3],$m[4],$english,$thai);
}, $text) ?? $text;
// Two-month range with a shared year: 5 January–14 August 2026.
$text = preg_replace_callback('/\b(\d{1,2})\s+('.$months.')\s*[–-]\s*(\d{1,2})\s+('.$months.')\s+(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[2],$m[5],$english,$thai).'–'.normalDate($m[3],$m[4],$m[5],$english,$thai);
}, $text) ?? $text;
// Hyphenated two-month range: 19-Feb–29-May-2026.
$text = preg_replace_callback('/\b(\d{1,2})-('.$months.')\s*[–-]\s*(\d{1,2})-('.$months.')-(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[2],$m[5],$english,$thai).'–'.normalDate($m[3],$m[4],$m[5],$english,$thai);
}, $text) ?? $text;
// A full single date using a month name or abbreviation.
$text = preg_replace_callback('/\b(\d{1,2})(?:-|\s+)('.$months.')(?:-|\s+)(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[2],$m[3],$english,$thai);
}, $text) ?? $text;
// Period descriptions without a repeated year, all known to be in 2026.
$text = preg_replace_callback('/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\s+('.$months.')(?=\s+(?:commits|accounting|May|April|January|February|March|June|July|August))/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[3],2026,$english,$thai).'–'.normalDate($m[2],$m[3],2026,$english,$thai);
}, $text) ?? $text;
// Commit-period shorthand: 30-Apr through 08-May.
$text = preg_replace_callback('/\b(\d{1,2})-('.$months.')\s+through\s+(\d{1,2})-('.$months.')\b/u', static function ($m) use ($english,$thai) {
return normalDate($m[1],$m[2],2026,$english,$thai).'–'.normalDate($m[3],$m[4],2026,$english,$thai);
}, $text) ?? $text;
return $text;
}
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
$updated = 0;
foreach ($iterator as $file) {
if (!$file->isFile() || !str_starts_with($file->getFilename(), '200-WMS-')) continue;
$path = $file->getPathname();
$extension = strtolower($file->getExtension());
if ($extension === 'md') {
$before = file_get_contents($path);
if ($before === false) continue;
$after = normalizeDates($before, $english, $thai);
if ($after !== $before) {
file_put_contents($path, $after);
$updated++;
}
continue;
}
if (!in_array($extension, ['docx','xlsx'], true)) continue;
$zip = new ZipArchive();
if ($zip->open($path) !== true) continue;
$changed = false;
for ($i=0; $i<$zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_ends_with($name, '.xml')) continue;
$before = $zip->getFromIndex($i);
$after = normalizeDates($before, $english, $thai);
if ($after === $before) continue;
$dom = new DOMDocument();
if (!$dom->loadXML($after)) throw new RuntimeException("Invalid XML after date conversion: {$path}::{$name}");
$zip->addFromString($name, $after);
$changed = true;
}
$zip->close();
if ($changed) $updated++;
}
echo "Updated {$updated} BRN WMS document(s).\n";
@@ -0,0 +1,7 @@
<table class="document-control">
<tbody>
<tr><th>Document</th><td>{{documentType}}</td><th>Release</th><td>{{release}}</td></tr>
<tr><th>Project</th><td>{{projectName}}</td><th>Project code</th><td>{{projectCode}}</td></tr>
<tr><th>Project period</th><td colspan="3">{{projectPeriod}}</td></tr>
</tbody>
</table>
+3 -6
View File
@@ -1,7 +1,4 @@
<!-- Repeating page footer: rule, standard reference at the left, document
abbreviation at the right, matching the audited reference package. -->
<div style="-webkit-print-color-adjust:exact; print-color-adjust:exact; font-family:'BRN Thai',Arial,sans-serif; margin:0; padding:0; position:relative; width:210mm;">
<div style="border-top:.75pt solid #111; left:30mm; position:absolute; top:0; width:160mm;"></div>
<div style="color:#111; font-size:9pt; left:30mm; position:absolute; top:1.2mm;">{{standard}}</div>
<div style="color:#111; font-size:9pt; position:absolute; right:20mm; text-align:right; top:1.2mm;">{{footerTag}}</div>
<div style="border-top:1.5pt solid #1f2933; color:#52606d; display:flex; font-family:'BRN Thai',Arial,sans-serif; font-size:8pt; justify-content:space-between; margin:0 16mm; padding-top:2.5mm; width:calc(100% - 32mm);">
<span>ISO/IEC 29110-4-1:2018</span>
<span>{{projectCode}}</span>
</div>
+8 -14
View File
@@ -1,14 +1,8 @@
<!-- Repeating page header reproducing the corporate identity of the audited
reference package. Every position is in millimetres measured from the
reference scan: logo at 39.4/11.0, grey ribbon 67.5–132.2, blue banner
127.2–197.2 with a 13.8mm diagonal, a rule under the company name from
55.4mm, and the address block beneath it. The Latin face is Liberation
Sans, metric-compatible with the Arial of the reference. -->
<div style="-webkit-print-color-adjust:exact; box-sizing:border-box; print-color-adjust:exact; top:-5.5mm; font-family:'Liberation Sans',Arial,Helvetica,sans-serif; height:27mm; margin:0; padding:0; position:relative; width:210mm;">
<div style="background:{{ciGrey}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 16mm 100%); height:4.3mm; left:67.5mm; position:absolute; top:8.9mm; width:64.7mm;"></div>
<div style="background:{{ciBlue}}; box-sizing:border-box; clip-path:polygon(0 0, 100% 0, 100% 100%, 13.8mm 100%); color:#fff; font-size:13.5pt; height:12.5mm; left:127.2mm; line-height:14.1mm; padding:0 7.8mm 0 0; position:absolute; text-align:right; top:8.6mm; white-space:nowrap; width:70mm;">{{documentTitle}}</div>
<img src="{{logoPath}}" alt="" style="height:11.7mm; left:39.4mm; object-fit:contain; position:absolute; top:11mm; width:12.1mm;">
<div style="color:#111; font-family:'Liberation Sans Narrow','Arial Narrow',Arial,sans-serif; font-size:10.6pt; font-weight:700; left:56.8mm; line-height:1; position:absolute; top:16.5mm; white-space:nowrap;">B.R.N. ENTERPRISE CO., LTD.</div>
<div style="background:#111; height:.45mm; left:55.4mm; position:absolute; top:19.6mm; width:84.5mm;"></div>
<div style="color:#111; font-size:5pt; left:56.8mm; line-height:2.4mm; position:absolute; top:20.07mm; white-space:nowrap;">1011 Supalai Grand Tower, 7<sup>th</sup> Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 &nbsp;&nbsp;Fax. (+66) 2687 0255</div>
</div>
<header class="document-header">
<img class="document-header__logo" src="{{logoPath}}" alt="B.R.N. Enterprise Co., Ltd.">
<div class="document-header__company">
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 &nbsp; Fax. (+66) 2687 0255</p>
</div>
<div class="document-header__title">{{documentTitle}}</div>
</header>
+71 -93
View File
@@ -1,19 +1,15 @@
/* Page box and palette measured from the audited reference package:
text block 30mm from the left and 20mm from the right, header band ending
at 25mm, footer rule at 272.8mm. */
@page {
size: A4;
margin: 32mm 20mm 24mm 30mm;
margin: 18mm 16mm 28mm;
}
:root {
--brn-blue: #0797d5; /* corporate blue, sampled from the CI header asset */
--brn-grey: #8e8e8e; /* header ribbon grey, measured from the reference */
--brn-red: #e01f26; /* corporate red of the logo mark */
--brn-ink: #111111;
--brn-muted: #333333;
--brn-line: #595959; /* table rule */
--brn-tint: #fff3f6; /* header-row tint used across the reference tables */
--brn-blue: #0789c9;
--brn-red: #bd0e2d;
--brn-ink: #1f2933;
--brn-muted: #52606d;
--brn-line: #aab7c4;
--brn-pale-blue: #eaf6fc;
}
* { box-sizing: border-box; }
@@ -29,6 +25,51 @@ body { margin: 0; }
.delivery-document { width: 100%; }
.document-header {
align-items: center;
border-bottom: 1.5pt solid var(--brn-blue);
display: flex;
gap: 9mm;
margin: 0 0 7mm;
min-height: 22mm;
padding: 0 0 3mm;
}
.document-header__logo {
flex: 0 0 auto;
height: 16mm;
object-fit: contain;
width: 16mm;
}
.document-header__company { flex: 1; min-width: 0; }
.document-header__company-name {
font-size: 13pt;
font-weight: 700;
letter-spacing: .03em;
margin: 0;
}
.document-header__address {
color: var(--brn-muted);
font-size: 7.5pt;
line-height: 1.35;
margin: 1mm 0 0;
}
.document-header__title {
background: var(--brn-blue);
color: #fff;
font-size: 15pt;
font-weight: 500;
flex: 0 1 72mm;
overflow-wrap: anywhere;
min-width: 52mm;
padding: 4mm 6mm;
text-align: center;
}
.document-control {
border-collapse: collapse;
margin: 0 0 7mm;
@@ -39,114 +80,51 @@ body { margin: 0; }
.document-control th,
.document-control td {
border: .5pt solid var(--brn-line);
padding: 2mm 3mm;
padding: 2.2mm 3mm;
text-align: left;
vertical-align: top;
}
.document-control th {
background: var(--brn-tint);
background: var(--brn-pale-blue);
font-weight: 700;
white-space: nowrap;
width: 1%;
width: 26%;
}
/* Section headings in the reference are plain black text, not coloured bars. */
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); font-weight: 400; }
h1 { display: none; } /* the document name is carried by the page header */
h2 { font-size: 11.5pt; margin: 6mm 0 2mm; }
h3 { font-size: 10.5pt; margin: 4mm 0 2mm; }
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); }
h1 { font-size: 18pt; margin: 0 0 5mm; }
h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 9mm 0 4mm; padding-left: 3mm; }
h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 6mm 0 3mm; }
p { margin: 0 0 3mm; }
ul, ol { margin: 0 0 3.5mm; padding-left: 7mm; }
li { margin: 0 0 1mm; }
code {
font-family: "DejaVu Sans Mono", "Courier New", monospace;
font-size: .9em;
letter-spacing: 0;
/* Inline paths must break too, or they set the cell's minimum width. */
overflow-wrap: anywhere;
}
p { margin: 0 0 3.5mm; }
table:not(.document-control) {
border-collapse: collapse;
border: .5pt solid var(--brn-line);
break-inside: auto;
border: .75pt solid #718096;
font-size: 9pt;
margin: 0 0 5mm;
table-layout: auto;
table-layout: fixed;
width: 100%;
}
table:not(.document-control) th,
table:not(.document-control) td {
border: .5pt solid var(--brn-line);
break-inside: avoid;
/* Thai runs have no spaces, so cells must be allowed to break anywhere;
"anywhere" (not "break-word") also lets the table shrink to the page. */
overflow-wrap: anywhere;
padding: 1.6mm 2mm;
text-align: left;
padding: 2mm;
vertical-align: top;
word-break: normal;
}
table:not(.document-control) th { background: var(--brn-tint); font-weight: 400; }
/* Column alignment carried over from the Markdown separator row. */
table:not(.document-control) th.a-right,
table:not(.document-control) td.a-right { text-align: right; }
table:not(.document-control) th.a-center,
table:not(.document-control) td.a-center { text-align: center; }
/* Short columns (IDs, dates, counts) shrink to their content instead of
taking an equal share of the table width. */
table:not(.document-control) th.tight,
table:not(.document-control) td.tight { white-space: nowrap; width: 1%; }
table:not(.document-control) tr.group td { background: var(--brn-tint); font-weight: 600; }
table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
.approval-block__person { break-inside: avoid; margin: 0 0 6mm; }
.approval-block__person h3 { margin: 0 0 2mm; }
.approval-block__line {
border-bottom: .5pt solid var(--brn-ink);
display: inline-block;
min-width: 68mm;
vertical-align: baseline;
}
.approval-block { break-inside: avoid; margin-top: 9mm; }
.approval-block__person { margin: 5mm 0; min-height: 21mm; }
.approval-block__line { border-bottom: .5pt solid var(--brn-ink); display: inline-block; min-width: 70mm; }
.approval-fields {
line-height: 1.55;
margin: 0;
padding: 0;
line-height: 1.62;
margin: 0 0 8mm;
padding: 2.5mm 0 4mm;
}
/* Signature blocks: heading and its table stay together, in the reference layout. */
.signature-block { break-inside: avoid; margin: 0 0 5mm; }
.signature-block h2.signature-heading { border-left: none; font-size: 11pt; margin: 5mm 0 2mm; padding-left: 0; }
/* Fixed columns so the signature grid always fits the text block width. */
table.signature-table { table-layout: fixed; }
table.signature-table td { height: 13mm; }
table.signature-table th.tight,
table.signature-table td.tight { white-space: normal; width: auto; }
table.signature-table th:nth-child(1), table.signature-table td:nth-child(1) { width: 32%; }
table.signature-table th:nth-child(2), table.signature-table td:nth-child(2) { width: 26%; }
table.signature-table th:nth-child(3), table.signature-table td:nth-child(3) { width: 22%; }
table.signature-table th:nth-child(4), table.signature-table td:nth-child(4) { width: 20%; }
/* The document-control block repeats its label column, so labels must not stretch. */
.document-control th { width: 1%; }
/* Wide grids (schedule, register, test cases) stay portrait like the
reference package, so they take a smaller face to fit the text block. */
table.dense { font-size: 7.2pt; }
table.dense th, table.dense td { padding: 1mm 1.2mm; }
/* Figures in the Software Design: full column width, caption under the image. */
.delivery-document figure.figure { margin: 8pt 0 12pt; text-align: center; break-inside: avoid; page-break-inside: avoid; }
.delivery-document figure.figure img { max-width: 100%; max-height: 225mm; border: .5pt solid #bfbfbf; }
.delivery-document figure.figure figcaption { margin-top: 4pt; font-size: 9.5pt; font-weight: 700; }
-858
View File
@@ -1,858 +0,0 @@
#!/usr/bin/env python3
"""Convert the rendered delivery package from PDF to Word.
The work products are authored in Markdown and rendered to PDF by
render-sdlc.mjs; the .docx beside each .pdf is a layout reconstruction of that
PDF, so the Word file keeps the page the reviewer signed off rather than being
laid out a second time from the source.
The corporate identity is the one thing not taken from the reconstruction.
A PDF has no notion of a running header, so the converter rebuilds the CI band
and the footer rule as ordinary content at the top and bottom of every page,
losing the ribbon geometry and the alignment with it. Both are therefore
stripped out again here and replaced by a real Word header and footer: the band
is the same assets/header.html the PDF is printed from, captured by
render-ci-band.mjs, so it lands in the same place on every page.
Requires pdf2docx: pip install -r requirements.txt
Usage:
convert-to-docx.py SOURCE_DIRECTORY DELIVERY_DIRECTORY [BAND_DIRECTORY]
"""
import copy
import json
import re
import subprocess
import sys
import tempfile
import unicodedata
from pathlib import Path
from docx import Document
from docx.enum.text import WD_TAB_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Mm, Pt, RGBColor, Twips
from docx.text.paragraph import Paragraph
from pdf2docx import Converter
HERE = Path(__file__).resolve().parent
# Page box measured from the audited reference package, as in sdlc-delivery.css.
BODY_TOP_MM = 32
FOOTER_FROM_BOTTOM_MM = 24.2 # footer rule at 272.8mm on a 297mm page
BAND_WIDTH_MM = 210
STANDARD = 'ISO/IEC 29110-4-1:2018'
RULE_SIZE = '4' # .5pt table rule, as printed
RULE_COLOUR = '595959' # --brn-line from sdlc-delivery.css
THAI_FONT = 'Leelawadee UI' # the face embedded in the PDF as "BRN Thai"
MONO_FONT = 'Courier New' # code spans; DejaVu Sans Mono in the PDF
INK = RGBColor(0x11, 0x11, 0x11)
# The furniture pdf2docx rebuilds in the body, recognised by text that appears
# nowhere in the authored sources.
BAND_MARKS = ('B.R.N. ENTERPRISE', STANDARD)
LINE_MARKS = ('Tel. (+66)', '1011 Supalai Grand Tower')
def text_of(element):
"""The text Word shows for an element: its w:t runs, tabs and breaks.
Not itertext(): the converter leaves other text-bearing nodes in its runs,
which would read every paragraph twice over.
"""
parts = []
for node in element.iter(qn('w:t'), qn('w:tab'), qn('w:br')):
if node.tag == qn('w:t'):
parts.append(node.text or '')
elif node.getparent().tag == qn('w:r'):
# A w:tab under w:tabs is a tab-stop definition, not a character.
parts.append('\t' if node.tag == qn('w:tab') else '\n')
return ''.join(parts)
def is_blank(element):
return (
element.tag == qn('w:p')
and not text_of(element).strip()
and element.find('.//' + qn('w:sectPr')) is None
and not element.findall('.//' + qn('w:drawing'))
)
def strip_reconstructed_furniture(document):
"""Remove the per-page letterhead and footer rule pdf2docx puts in the body."""
body = document.element.body
removed = 0
for block in list(body):
if block.getparent() is None:
continue
if block.tag == qn('w:tbl'):
# The converter sometimes lays a page's furniture and its content
# into one table, so furniture is taken out a row at a time.
for row in list(block.findall(qn('w:tr'))):
if any(mark in text_of(row) for mark in BAND_MARKS + LINE_MARKS):
block.remove(row)
removed += 1
if block.find(qn('w:tr')) is None:
previous = block.getprevious()
if previous is not None and is_blank(previous):
body.remove(previous)
body.remove(block)
elif block.tag == qn('w:p') and any(mark in text_of(block) for mark in LINE_MARKS):
body.remove(block)
removed += 1
return removed
DECIMAL = re.compile(r'^-?\d+\.\d+$')
BORDER_EDGES = {'top', 'bottom', 'left', 'right', 'insideH', 'insideV', 'tl2br', 'tr2bl'}
def normalise_ooxml(document):
"""Repair the measurements the converter writes outside the schema.
Widths, sizes and border weights are whole units in OOXML, and a colour is
six hex digits with no leading hash. The converter writes the numbers it
measured off the page instead - "5.599999999999909", "#585858" - which Word
is free to interpret as it likes, so borders come out at weights nobody
asked for.
"""
fixed = 0
for element in document.element.body.iter():
for name, value in list(element.attrib.items()):
local = name.rpartition('}')[2]
if DECIMAL.match(value):
element.set(name, str(round(float(value))))
fixed += 1
elif local in ('color', 'fill') and value.startswith('#'):
element.set(name, value[1:])
fixed += 1
return fixed
def match_rule_weight(document):
"""Draw every table rule at the .5pt the PDF is printed with."""
edges = 0
for borders in document.element.body.iter():
if borders.tag not in (qn('w:tblBorders'), qn('w:tcBorders')):
continue
for edge in borders:
if edge.tag.rpartition('}')[2] not in BORDER_EDGES:
continue
if edge.get(qn('w:val')) in (None, 'nil', 'none'):
continue
edge.set(qn('w:sz'), RULE_SIZE)
edge.set(qn('w:color'), RULE_COLOUR)
edges += 1
return edges
def drop_padding(document):
"""Remove the blank paragraphs the converter pads each page out with.
A run of them at the foot of a page is pure whitespace - the section break
already ends the page - and a run between two blocks only ever needs one.
"""
body = document.element.body
children = list(body)
removed = 0
index = 0
while index < len(children):
if not is_blank(children[index]):
index += 1
continue
start = index
while index < len(children) and is_blank(children[index]):
index += 1
following = children[index] if index < len(children) else None
ends_page = following is None or (
following.tag == qn('w:p') and following.find('.//' + qn('w:sectPr')) is not None
)
for element in children[start + (0 if ends_page else 1):index]:
body.remove(element)
removed += 1
return removed
R_EMBED = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed'
LOGO_MIN_PX = 100
def strip_logo_images(document):
"""Drop the letterhead logo left loose in the body.
The converter draws each bullet as a small image, so only the logo is
removed: it is square and at least LOGO_MIN_PX, whereas anything smaller is
a list marker. The Software Design figures are wider than they are tall,
so they are kept.
"""
def is_logo(image):
width, height = image.px_width, image.px_height
return max(width, height) >= LOGO_MIN_PX and 0.8 <= width / max(height, 1) <= 1.25
logos = {
relationship_id
for relationship_id, part in document.part.related_parts.items()
if getattr(part, 'image', None) is not None and is_logo(part.image)
}
removed = 0
for drawing in list(document.element.body.iter(qn('w:drawing'))):
blip = drawing.find('.//' + qn('a:blip'))
if blip is not None and blip.get(R_EMBED) in logos:
drawing.getparent().remove(drawing)
removed += 1
return removed
# ---------------------------------------------------------------------------
# Fonts
# ---------------------------------------------------------------------------
def normalise_fonts(document):
"""Point every run at a face Word actually has.
The PDF embeds its faces as Type3 fonts, so the converter names runs after
the PDF objects - "Type3 (4 0 R)" - and gives them no complex-script face
or size. Word then sets Latin text in a fallback serif and Thai text at the
default size, which is the mixed, jumping type the conversion showed.
"""
for run in document.element.body.iter(qn('w:r')):
properties = run.find(qn('w:rPr'))
if properties is None:
properties = OxmlElement('w:rPr')
run.insert(0, properties)
fonts = properties.find(qn('w:rFonts'))
face = MONO_FONT if fonts is not None and 'Mono' in (fonts.get(qn('w:ascii')) or '') else THAI_FONT
if fonts is None:
fonts = OxmlElement('w:rFonts')
style = properties.find(qn('w:rStyle'))
properties.insert(1 if style is not None else 0, fonts)
for attribute in ('w:ascii', 'w:hAnsi', 'w:cs', 'w:eastAsia'):
fonts.set(qn(attribute), face)
size = properties.find(qn('w:sz'))
if size is not None and properties.find(qn('w:szCs')) is None:
complex_size = OxmlElement('w:szCs')
complex_size.set(qn('w:val'), size.get(qn('w:val')))
size.addnext(complex_size)
# ---------------------------------------------------------------------------
# Bullets
# ---------------------------------------------------------------------------
BULLET = '•'
BULLET_TEXT_MM = 7 # ul padding-left in sdlc-delivery.css
BULLET_HANG_MM = 4
def text_block_left_mm(section):
# The PDF text block starts 30mm in, or 15mm on the landscape work products.
return 15 if section.page_width > section.page_height else 30
def unwrap_list_tables(document):
"""Turn the tables the converter builds out of bullet lists back into text.
A short list is sometimes laid out as a grid - bullet image, item, empty
cell - with stray bottom borders that print as loose rules. Each row goes
back into the body as a paragraph of its own.
"""
body = document.element.body
items = []
for table in [child for child in body if child.tag == qn('w:tbl')]:
rows = table.findall(qn('w:tr'))
entries = []
for row in rows:
cells = row.findall(qn('w:tc'))
worded = [cell for cell in cells if text_of(cell).strip()]
marked = [cell for cell in cells if cell.findall('.//' + qn('w:drawing')) and not text_of(cell).strip()]
if len(worded) != 1 or not marked:
entries = None
break
entries.append(worded[0])
if not rows or entries is None:
continue
for cell in entries:
for paragraph in cell.findall(qn('w:p')):
if text_of(paragraph).strip():
table.addprevious(paragraph)
items.append(paragraph)
body.remove(table)
return items
def take_bullet_images(document):
"""Collect the list items drawn with an image marker, dropping the image."""
items = []
for paragraph in [child for child in document.element.body if child.tag == qn('w:p')]:
drawings = paragraph.findall('.//' + qn('w:drawing'))
if not drawings or not text_of(paragraph).strip():
continue
for drawing in drawings:
run = drawing.getparent()
run.remove(drawing)
if run.tag == qn('w:r') and not text_of(run).strip():
run.getparent().remove(run)
items.append(paragraph)
return items
def make_bullet(document, paragraph):
section = document.sections[0]
item = Paragraph(paragraph, document._body)
text_mm = text_block_left_mm(section) + BULLET_TEXT_MM
item.paragraph_format.left_indent = Twips(round(Mm(text_mm).twips - section.left_margin.twips))
item.paragraph_format.first_line_indent = -Mm(BULLET_HANG_MM)
first = paragraph.find(qn('w:r'))
marker = OxmlElement('w:r')
if first is not None and first.find(qn('w:rPr')) is not None:
marker.append(copy.deepcopy(first.find(qn('w:rPr'))))
text = OxmlElement('w:t')
text.text = BULLET
marker.append(text)
marker.append(OxmlElement('w:tab'))
properties = paragraph.find(qn('w:pPr'))
if properties is not None:
properties.addnext(marker)
else:
paragraph.insert(0, marker)
# ---------------------------------------------------------------------------
# Text from the source
# ---------------------------------------------------------------------------
APPROVAL_FIELD = re.compile(r'^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):')
SEPARATOR_CELL = re.compile(r'^:?-{3,}:?$')
INLINE = re.compile(r'\*\*(.+?)\*\*|`([^`]+)`|\\([\\*_`~])')
def letters(text):
"""The characters two renderings of the same text must share."""
return ''.join(
character for character in unicodedata.normalize('NFC', text)
if unicodedata.category(character)[0] in 'LMN'
).lower()
def read_source(markdown):
"""Blocks and table cells of a work product, as the reader sees them."""
body = re.sub(r'^#\s+.+\n+', '', markdown, count=1, flags=re.M)
body = re.sub(r'^<!--.*?-->\s*$', '', body, flags=re.M)
blocks, cells, tables, paragraph = [], [], [], []
bullets = set()
table = None
def flush():
if paragraph:
joined = ''
for index, line in enumerate(paragraph):
hard = re.search(r'\s{2,}$', line) or APPROVAL_FIELD.match(line.strip())
joined += line.strip() + ('\n' if hard else (' ' if index < len(paragraph) - 1 else ''))
blocks.append(joined.rstrip('\n'))
paragraph.clear()
for line in body.splitlines():
stripped = line.strip()
if stripped.startswith('|') and stripped.endswith('|'):
flush()
parts = [part.strip() for part in stripped[1:-1].split('|')]
if table is None:
table = {'after': blocks[-1] if blocks else None, 'rows': []}
tables.append(table)
if not all(SEPARATOR_CELL.match(part) for part in parts):
row = [re.sub(r'<br\s*/?>', '\n', part) for part in parts]
cells.extend(row)
table['rows'].append(row)
continue
table = None
if not stripped:
flush()
continue
heading = re.match(r'^#{1,3}\s+(.+)$', stripped)
bullet = re.match(r'^[-*]\s+(.+)$', stripped)
ordered = re.match(r'^\d+\.\s+.+$', stripped)
if heading or bullet or ordered:
flush()
# A numbered item keeps its number: the PDF prints it as text.
blocks.append(heading.group(1) if heading else bullet.group(1) if bullet else stripped)
if bullet:
bullets.add(len(blocks) - 1)
continue
paragraph.append(line)
flush()
return blocks, cells, tables, bullets
def index_texts(texts):
exact, by_length = {}, {}
for text in texts:
key = letters(text)
if key and key not in exact:
exact[key] = text
by_length.setdefault(len(key), []).append(key)
return exact, by_length
def is_subsequence(short, long):
remaining = iter(long)
return all(character in remaining for character in short)
def source_for(text, exact, by_length):
"""The authored text a converted fragment came from, if it is unambiguous.
Same letters means only spacing and punctuation differ. Failing that, a
fragment that is the source with up to three characters missing - the
converter drops characters, it never invents them - is accepted when just
one source text fits.
"""
key = letters(text)
if not key:
return None
if key in exact:
return exact[key]
if len(key) < 8:
return None
fits = {
exact[candidate]
for length in range(len(key) + 1, len(key) + 4)
for candidate in by_length.get(length, ())
if is_subsequence(key, candidate)
}
return fits.pop() if len(fits) == 1 else None
def write_text(paragraph, text):
"""Replace a paragraph's runs with the authored text, keeping its look."""
template = paragraph.find('.//' + qn('w:rPr'))
template = copy.deepcopy(template) if template is not None else OxmlElement('w:rPr')
for child in list(paragraph):
if child.tag != qn('w:pPr'):
paragraph.remove(child)
def run(content=None, bold=False, code=False, line_break=False):
element = OxmlElement('w:r')
properties = copy.deepcopy(template)
fonts = properties.find(qn('w:rFonts'))
if fonts is not None:
face = MONO_FONT if code else THAI_FONT
for attribute in ('w:ascii', 'w:hAnsi', 'w:cs', 'w:eastAsia'):
fonts.set(qn(attribute), face)
weight = properties.find(qn('w:b'))
for complex_weight in properties.findall(qn('w:bCs')):
properties.remove(complex_weight)
if bold:
if weight is None:
weight = OxmlElement('w:b')
(fonts.addnext(weight) if fonts is not None else properties.insert(0, weight))
weight.attrib.pop(qn('w:val'), None)
weight.addnext(OxmlElement('w:bCs'))
elif weight is not None:
weight.set(qn('w:val'), '0')
element.append(properties)
if line_break:
element.append(OxmlElement('w:br'))
else:
node = OxmlElement('w:t')
node.text = content
node.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
element.append(node)
paragraph.append(element)
for number, line in enumerate(text.split('\n')):
if number:
run(line_break=True)
cursor = 0
for match in INLINE.finditer(line):
if match.start() > cursor:
run(line[cursor:match.start()])
if match.group(1) is not None:
run(match.group(1), bold=True)
elif match.group(2) is not None:
run(match.group(2), code=True)
else:
run(match.group(3))
cursor = match.end()
if cursor < len(line):
run(line[cursor:])
def split_blocks(key, block_keys):
"""The run of consecutive source blocks a converted paragraph is made of."""
for start, first in enumerate(block_keys):
if not first or not key.startswith(first):
continue
combined, end = first, start
while len(combined) < len(key) and end + 1 < len(block_keys) and end - start < 7:
end += 1
combined += block_keys[end]
if combined == key:
return list(range(start, end + 1))
if not key.startswith(combined):
break
return None
def restore_text(document, markdown, bullets):
"""Put the authored wording back where the converter mangled it.
PDF text has no word spaces for Thai, so the conversion runs Thai words
together, breaks a paragraph into one paragraph per printed line, and now
and then drops a character. Wherever a converted paragraph or table cell
can be traced to exactly one piece of the source, its text is rewritten
from the source; anything that cannot be traced is left as converted.
"""
blocks, cells, _, bullet_blocks = read_source(markdown)
block_keys = [letters(block) for block in blocks]
known = {id(paragraph) for paragraph in bullets}
block_exact, block_lengths = index_texts(blocks)
cell_exact, cell_lengths = index_texts(cells)
body = document.element.body
rewritten = merged = 0
for cell in body.iter(qn('w:tc')):
if cell.find('.//' + qn('w:tbl')) is not None:
continue
paragraphs = cell.findall(qn('w:p'))
text = '\n'.join(text_of(paragraph) for paragraph in paragraphs)
source = source_for(text, cell_exact, cell_lengths)
if source is None or '___' in source or source == text:
continue
write_text(paragraphs[0], source)
for extra in paragraphs[1:]:
cell.remove(extra)
rewritten += 1
children = [child for child in body if child.tag == qn('w:p') and text_of(child).strip()]
index = 0
while index < len(children):
paragraph = children[index]
source = source_for(text_of(paragraph), block_exact, block_lengths)
span = 1
if source is None:
# A paragraph the converter split into one paragraph per line.
combined = text_of(paragraph)
follower = paragraph
for count in range(2, 9):
follower = follower.getnext()
if follower is None or follower.tag != qn('w:p') or not text_of(follower).strip():
break
combined += text_of(follower)
if letters(combined) in block_exact:
source, span = block_exact[letters(combined)], count
break
if source is None:
# Several list items or paragraphs the converter ran into one.
parts = split_blocks(letters(text_of(paragraph)), block_keys)
if parts and not any('___' in blocks[part] for part in parts):
previous = paragraph
for number, part in enumerate(parts):
target = paragraph
if number:
target = OxmlElement('w:p')
if paragraph.find(qn('w:pPr')) is not None:
target.append(copy.deepcopy(paragraph.find(qn('w:pPr'))))
template = paragraph.find('.//' + qn('w:rPr'))
run = OxmlElement('w:r')
run.append(copy.deepcopy(template) if template is not None else OxmlElement('w:rPr'))
target.append(run)
previous.addnext(target)
previous = target
write_text(target, blocks[part])
if part in bullet_blocks and id(target) not in known:
bullets.append(target)
known.add(id(target))
rewritten += len(parts)
index += 1
continue
if source is not None and '___' not in source:
for extra in children[index + 1:index + span]:
body.remove(extra)
merged += 1
write_text(paragraph, source)
rewritten += 1
index += span
return rewritten, merged
TINT = 'FFF3F6' # header-row tint used across the reference tables
TABLE_PT = 9
CELL_MARGIN_MM = (1.6, 2) # vertical, horizontal padding of a table cell
PRINTED_TEXT_WIDTH_MM = {False: 160, True: 267} # portrait, landscape
DISTINCTIVE_LETTERS = 15 # shorter cells - labels, IDs, dates - recur elsewhere
DROPPED_CHARACTER_SLACK = 3
def present(key, document_key):
"""Whether a cell's whole text reached the document.
The whole cell, not a fragment of it: short Thai phrases recur across a
work product, so a partial match proves nothing. Its two halves in order,
a few characters apart at most, still count, as the converter sometimes
drops a character mid-cell.
"""
if key in document_key:
return True
half = len(key) // 2
start = document_key.find(key[:half])
while start >= 0:
second = document_key.find(key[half:], start + half)
if 0 <= second - (start + half) <= DROPPED_CHARACTER_SLACK:
return True
start = document_key.find(key[:half], start + 1)
return False
def dropped_tables(document, markdown):
"""Source tables of which not one distinctive cell reached the document."""
_, _, tables, _ = read_source(markdown)
document_key = letters(text_of(document.element.body))
missing = []
for table in tables:
keys = [letters(cell) for row in table['rows'] for cell in row]
keys = [key for key in keys if len(key) >= DISTINCTIVE_LETTERS]
if keys and not any(present(key, document_key) for key in keys):
missing.append(table)
return missing
def build_table(document, rows):
section = document.sections[0]
landscape = section.page_width > section.page_height
width = Mm(PRINTED_TEXT_WIDTH_MM[landscape]).twips
columns = max(len(row) for row in rows)
longest = [max(len(letters(row[i])) if i < len(row) else 0 for row in rows) for i in range(columns)]
weights = [min(max(value, 8), 80) for value in longest]
widths = [round(width * weight / sum(weights)) for weight in weights]
def element(tag, **attributes):
node = OxmlElement(tag)
for name, value in attributes.items():
node.set(qn(name), str(value))
return node
table = element('w:tbl')
properties = element('w:tblPr')
properties.append(element('w:tblW', **{'w:w': width, 'w:type': 'dxa'}))
indent = round(Mm(text_block_left_mm(section)).twips - section.left_margin.twips)
properties.append(element('w:tblInd', **{'w:w': indent, 'w:type': 'dxa'}))
borders = element('w:tblBorders')
for edge in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
borders.append(element('w:' + edge, **{'w:val': 'single', 'w:sz': RULE_SIZE, 'w:space': 0, 'w:color': RULE_COLOUR}))
properties.append(borders)
properties.append(element('w:tblLayout', **{'w:type': 'fixed'}))
margins = element('w:tblCellMar')
vertical, horizontal = (round(Mm(value).twips) for value in CELL_MARGIN_MM)
for edge, value in (('top', vertical), ('left', horizontal), ('bottom', vertical), ('right', horizontal)):
margins.append(element('w:' + edge, **{'w:w': value, 'w:type': 'dxa'}))
properties.append(margins)
table.append(properties)
grid = element('w:tblGrid')
for column_width in widths:
grid.append(element('w:gridCol', **{'w:w': column_width}))
table.append(grid)
for number, cells in enumerate(rows):
row = element('w:tr')
row_properties = element('w:trPr')
row_properties.append(element('w:cantSplit'))
if number == 0:
row_properties.append(element('w:tblHeader'))
row.append(row_properties)
for column in range(columns):
cell = element('w:tc')
cell_properties = element('w:tcPr')
cell_properties.append(element('w:tcW', **{'w:w': widths[column], 'w:type': 'dxa'}))
if number == 0:
cell_properties.append(element('w:shd', **{'w:val': 'clear', 'w:color': 'auto', 'w:fill': TINT}))
cell.append(cell_properties)
paragraph = element('w:p')
paragraph_properties = element('w:pPr')
paragraph_properties.append(element('w:spacing', **{'w:before': 0, 'w:after': 0}))
paragraph.append(paragraph_properties)
template = element('w:r')
run_properties = element('w:rPr')
fonts = element('w:rFonts', **{'w:ascii': THAI_FONT, 'w:hAnsi': THAI_FONT, 'w:cs': THAI_FONT, 'w:eastAsia': THAI_FONT})
run_properties.append(fonts)
run_properties.append(element('w:color', **{'w:val': '111111'}))
run_properties.append(element('w:sz', **{'w:val': TABLE_PT * 2}))
run_properties.append(element('w:szCs', **{'w:val': TABLE_PT * 2}))
template.append(run_properties)
paragraph.append(template)
write_text(paragraph, cells[column] if column < len(cells) else '')
cell.append(paragraph)
row.append(cell)
table.append(row)
return table
def restore_dropped_tables(document, markdown):
"""Put back tables the converter left out, where the source places them.
The converter occasionally drops a whole table that is plainly printed in
the PDF. It is rebuilt from the source in the style of the converted
tables, after the paragraph it follows in the source.
"""
restored = 0
body = document.element.body
for table in dropped_tables(document, markdown):
if not table['after'] or not table['rows']:
continue
anchor_key = letters(table['after'])
paragraphs = [child for child in body if child.tag == qn('w:p')]
anchor = next((child for child in paragraphs if letters(text_of(child)) == anchor_key), None)
if anchor is None:
# The block may share a paragraph with what precedes it.
endings = [child for child in paragraphs if anchor_key and letters(text_of(child)).endswith(anchor_key)]
anchor = endings[0] if len(endings) == 1 else None
if anchor is None:
continue
anchor.addnext(build_table(document, table['rows']))
restored += 1
return restored
def style_run(run, points):
run.font.size = Pt(points)
run.font.color.rgb = INK
fonts = OxmlElement('w:rFonts')
for attribute in ('w:ascii', 'w:hAnsi', 'w:cs', 'w:eastAsia'):
fonts.set(qn(attribute), THAI_FONT)
run._element.get_or_add_rPr().insert(0, fonts)
def drop_style(paragraph):
# The template's Header and Footer styles carry centre and right tab stops
# of their own, which catch the tab before the footer tag and centre it.
properties = paragraph._p.pPr
if properties is not None and properties.find(qn('w:pStyle')) is not None:
properties.remove(properties.find(qn('w:pStyle')))
def add_top_border(paragraph):
borders = OxmlElement('w:pBdr')
top = OxmlElement('w:top')
top.set(qn('w:val'), 'single')
top.set(qn('w:sz'), '6') # .75pt, as printed
top.set(qn('w:space'), '1')
top.set(qn('w:color'), '111111')
borders.append(top)
paragraph._p.get_or_add_pPr().append(borders)
def install_letterhead(document, band, footer_tag):
"""Give every page the CI band and footer rule as real Word furniture."""
for index, section in enumerate(document.sections):
section.top_margin = Mm(BODY_TOP_MM)
section.header_distance = Mm(0)
section.footer_distance = Mm(FOOTER_FROM_BOTTOM_MM)
# Only the first section carries the furniture; the rest inherit it.
section.header.is_linked_to_previous = index > 0
section.footer.is_linked_to_previous = index > 0
section = document.sections[0]
header = section.header.paragraphs[0]
drop_style(header)
# The band spans the page, so it starts outside the text block.
header.paragraph_format.left_indent = -section.left_margin
header.paragraph_format.space_before = Pt(0)
header.paragraph_format.space_after = Pt(0)
header.add_run().add_picture(str(band), width=Mm(BAND_WIDTH_MM))
footer = section.footer.paragraphs[0]
drop_style(footer)
footer.paragraph_format.space_before = Pt(0)
footer.paragraph_format.space_after = Pt(0)
footer.paragraph_format.tab_stops.add_tab_stop(
section.page_width - section.left_margin - section.right_margin,
WD_TAB_ALIGNMENT.RIGHT
)
add_top_border(footer)
style_run(footer.add_run(STANDARD), 9)
style_run(footer.add_run(f'\t{footer_tag}'), 9)
def convert(pdf, source, band, footer_tag):
docx = pdf.with_suffix('.docx')
converter = Converter(str(pdf))
try:
converter.convert(str(docx))
finally:
converter.close()
document = Document(str(docx))
strip_reconstructed_furniture(document)
strip_logo_images(document)
bullets = unwrap_list_tables(document) + take_bullet_images(document)
normalise_ooxml(document)
normalise_fonts(document)
markdown = source.read_text(encoding='utf8')
restore_text(document, markdown, bullets)
restore_dropped_tables(document, markdown)
for paragraph in bullets:
if paragraph.getparent() is not None:
make_bullet(document, paragraph)
drop_padding(document)
match_rule_weight(document)
install_letterhead(document, band, footer_tag)
document.save(str(docx))
return docx
def render_bands(source_root, band_root):
subprocess.run(
['node', str(HERE / 'render-ci-band.mjs'), str(source_root), str(band_root)],
check=True
)
return json.loads((Path(band_root) / 'bands.json').read_text(encoding='utf8'))
def main(argv):
if len(argv) < 2:
raise SystemExit(__doc__)
source_root, delivery_root = Path(argv[0]), Path(argv[1])
temporary = None
if len(argv) > 2:
bands = json.loads((Path(argv[2]) / 'bands.json').read_text(encoding='utf8'))
else:
temporary = tempfile.TemporaryDirectory()
bands = render_bands(source_root, temporary.name)
failures = []
for index, (relative, band) in enumerate(sorted(bands.items()), start=1):
pdf = delivery_root / Path(relative).with_suffix('.pdf')
try:
docx = convert(pdf, source_root / relative, Path(band['band']), band['footer'])
print(f'[{index}/{len(bands)}] {docx.relative_to(delivery_root)}')
except Exception as error: # noqa: BLE001 - reported, not swallowed
failures.append((pdf, error))
print(f'[{index}/{len(bands)}] FAILED {relative}: {error}', file=sys.stderr)
if temporary:
temporary.cleanup()
print(f'{len(bands) - len(failures)}/{len(bands)} documents converted')
if failures:
raise SystemExit(1)
if __name__ == '__main__':
main(sys.argv[1:])
-81
View File
@@ -1,81 +0,0 @@
// Render the corporate identity band used by the delivery package.
//
// The band is the same assets/header.html the PDF is printed with, captured as
// an image so its ribbon geometry survives into Word, where it is placed in a
// real page header instead of being reconstructed in the body.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '../..');
const assets = path.join(here, 'assets');
export const BAND_MM = { width: 210, height: 27 };
const SCALE = 3; // the band is drawn at 3x so it stays crisp in print
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
const slug = (title) => title.replace(/[^\w-]+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'band';
// Work products sit in the process folders; top-level files in sdlc/ are notes.
async function markdownFiles(directory, depth = 0) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const results = await Promise.all(entries.map(async (entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return markdownFiles(target, depth + 1);
return depth > 0 && entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
}));
return results.flat();
}
async function main() {
const [sourceRoot, outputRoot] = process.argv.slice(2);
if (!sourceRoot || !outputRoot) throw new Error('Usage: render-ci-band.mjs SOURCE_DIRECTORY OUTPUT_DIRECTORY');
const [template, logo] = await Promise.all([
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
fs.readFile(path.join(root, 'app/assets/images/logo.svg'))
]);
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
ciBlue: '#0797d5',
ciGrey: '#8e8e8e' // ribbon grey measured from the audited reference documents
};
await fs.mkdir(outputRoot, { recursive: true });
const sources = (await markdownFiles(sourceRoot)).sort();
const bands = {};
const rendered = new Map();
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ deviceScaleFactor: SCALE });
for (const source of sources) {
const markdown = await fs.readFile(source, 'utf8');
const title = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? 'BRN WMS';
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
if (!rendered.has(title)) {
const file = path.join(outputRoot, `${slug(title)}.png`);
await page.setContent(
'<!doctype html><meta charset="utf-8"><body style="margin:0;background:#fff">' +
`<div id="band" style="width:${BAND_MM.width}mm;height:${BAND_MM.height}mm;overflow:hidden;position:relative">` +
`${applyTemplate(template, { ...values, documentTitle: title })}</div></body>`,
{ waitUntil: 'networkidle' }
);
await page.locator('#band').screenshot({ type: 'png', path: file });
rendered.set(title, file);
}
bands[path.relative(sourceRoot, source)] = { title, footer, band: rendered.get(title) };
}
} finally {
await browser.close();
}
await fs.writeFile(path.join(outputRoot, 'bands.json'), `${JSON.stringify(bands, null, 2)}\n`);
console.log(`${rendered.size} band${rendered.size === 1 ? '' : 's'} rendered for ${sources.length} documents`);
}
main().catch((error) => { console.error(error); process.exit(1); });
-34
View File
@@ -1,34 +0,0 @@
// Rasterise the Software Design figures (scripts/sdlc-seed/figures.mjs) to PNG with the
// document font, so Thai labels print the same in the PDF and survive the Word conversion.
// Usage: node scripts/sdlc-delivery/render-figures.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { FIGURES } from '../sdlc-seed/figures.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const output = path.resolve(here, '../sdlc-seed/figures');
async function main() {
const font = await fs.readFile(path.join(here, 'assets/LeelawadeeUI.ttf'));
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${font.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
for (const [file, , draw] of Object.values(FIGURES)) {
const svg = draw();
const [, w, h] = svg.match(/width="(\d+)" height="(\d+)"/);
await page.setViewportSize({ width: Number(w), height: Number(h) });
await page.setContent(`<!doctype html><html><head><meta charset="utf-8"><style>${fontFace} html,body{margin:0}</style></head><body>${svg}</body></html>`);
await page.evaluate(() => document.fonts.ready);
await page.locator('svg').first().screenshot({ path: path.join(output, `${file}.png`) });
console.log(`Rendered figures/${file}.png`);
}
} finally {
await browser.close();
}
}
main().catch((error) => { console.error(error); process.exit(1); });
+40 -132
View File
@@ -1,5 +1,4 @@
import fs from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
@@ -14,13 +13,8 @@ const escapeHtml = (value) => value
.replaceAll('>', '&gt;');
const renderInline = (value) => escapeHtml(value)
// A literal <br> is the only markup allowed through, so a table cell can list
// several files on separate lines the way the reference package does.
.replaceAll('&lt;br&gt;', '<br>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/_{3,}/g, '<span class="approval-block__line"></span>')
.replace(/\\([\\*_`~])/g, '$1');
.replace(/`([^`]+)`/g, '<code>$1</code>');
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
@@ -36,67 +30,12 @@ function isSeparator(cells) {
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
const TIGHT_COLUMN_MAX = 12;
function columnAlignment(separatorCells) {
return separatorCells.map((cell) => {
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) return 'a-center';
if (right) return 'a-right';
return '';
});
}
function isControlBlock(lines) {
return tableCells(lines[0] ?? '')[0] === 'Document No';
}
// The document-control block is a label/value grid, not a data table: odd cells are
// labels, even cells are values, and a row with one pair spans the full width.
function renderControlBlock(lines) {
const rows = lines.map(tableCells);
const body = rows.map((cells) => {
const pairs = [];
for (let i = 0; i < cells.length; i += 2) pairs.push([cells[i], cells[i + 1] ?? '']);
const span = pairs.length === 1 ? ' colspan="3"' : '';
return `<tr>${pairs.map(([label, value]) => `<th>${renderInline(label)}</th><td${span}>${renderInline(value)}</td>`).join('')}</tr>`;
}).join('');
return `<table class="document-control"><tbody>${body}</tbody></table>`;
}
function renderTable(lines, className = '') {
const rows = lines.map(tableCells);
const header = rows[0];
const hasSeparator = isSeparator(rows[1] ?? []);
const align = hasSeparator ? columnAlignment(rows[1]) : header.map(() => '');
const data = hasSeparator ? rows.slice(2) : rows.slice(1);
// A row with only its first cell filled is a group heading (CR01, SR06, UN04,
// WP 1.0 …): it spans the full width and does not size the columns. Signature
// tables keep their blank cells for signing.
const isGroup = (row) => className !== 'signature-table' && header.length >= 3
&& Boolean(row[0]) && row.slice(1).every((cell) => !cell);
// A column whose longest cell is short (IDs, dates, counts, statuses) is
// marked "tight" so auto table layout shrinks it to its content instead of
// handing every column an equal share of the page width.
const widest = header.map((_, column) => Math.max(
(header[column] ?? '').length,
...data.filter((row) => !isGroup(row)).map((row) => (row[column] ?? '').replace(/[`*]/g, '').length)
));
const classFor = (column) => [
align[column] ?? '',
widest[column] <= TIGHT_COLUMN_MAX ? 'tight' : ''
].filter(Boolean).join(' ');
const cells = (tag, row) => row.map((cell, column) => {
const attr = classFor(column);
return `<${tag}${attr ? ` class="${attr}"` : ''}>${renderInline(cell)}</${tag}>`;
}).join('');
const classes = [className, header.length >= 8 ? 'dense' : ''].filter(Boolean).join(' ');
return `<table${classes ? ` class="${classes}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => isGroup(row)
? `<tr class="group"><td colspan="${header.length}">${renderInline(row[0])}</td></tr>`
: `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1);
const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}</${tag}>`).join('');
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
}
function renderParagraph(lines) {
@@ -109,7 +48,7 @@ function renderParagraph(lines) {
}).join('');
}
function markdownToHtml(markdown, baseDir) {
function markdownToHtml(markdown) {
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
const output = [];
let index = 0;
@@ -118,25 +57,9 @@ function markdownToHtml(markdown, baseDir) {
const line = lines[index];
if (!line.trim()) { index += 1; continue; }
// A figure: ![caption](relative/path.png), embedded so the PDF needs no external files.
const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (image) {
const data = readFileSync(path.resolve(baseDir, image[2])).toString('base64');
output.push(`<figure class="figure"><img src="data:image/png;base64,${data}" alt=""><figcaption>${renderInline(image[1])}</figcaption></figure>`);
index += 1;
continue;
}
if (isTableLine(line)) {
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
if (isControlBlock(table)) { output.push(renderControlBlock(table)); continue; }
const previous = output[output.length - 1] ?? '';
// Keep a signature heading and its table together on one page.
if (/^<h2 class="signature-heading">/.test(previous)) {
output.push(`<div class="signature-block">${output.pop()}${renderTable(table, 'signature-table')}</div>`);
continue;
}
output.push(renderTable(table));
continue;
}
@@ -144,9 +67,7 @@ function markdownToHtml(markdown, baseDir) {
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
const signature = level === 2 && /^ผู้(จัดทำเอกสาร|ตรวจสอบเอกสาร|อนุมัติ)/.test(heading[2]);
const attr = signature ? ' class="signature-heading"' : '';
output.push(`<h${level}${attr}>${renderInline(heading[2])}</h${level}>`);
output.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
index += 1;
continue;
}
@@ -158,23 +79,10 @@ function markdownToHtml(markdown, baseDir) {
continue;
}
if (/^\d+\.\s+/.test(line)) {
const items = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^\d+\.\s+/, ''))}</li>`);
output.push(`<ol>${items.join('')}</ol>`);
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^!\[/.test(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index]) && !/^\d+\.\s+/.test(lines[index])) paragraph.push(lines[index++]);
const isApproval = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd()));
const html = `<p${isApproval ? ' class="approval-fields"' : ''}>${renderParagraph(paragraph)}</p>`;
// Keep a signatory's heading and fields on one page.
if (isApproval && /^<h3>/.test(output[output.length - 1] ?? '')) {
output.push(`<div class="approval-block__person">${output.pop()}${html}</div>`);
} else {
output.push(html);
}
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]);
const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : '';
output.push(`<p${approvalClass}>${renderParagraph(paragraph)}</p>`);
}
return output.join('\n');
@@ -183,26 +91,32 @@ function markdownToHtml(markdown, baseDir) {
function extractDocument(markdown) {
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
const title = titleMatch?.[1] ?? 'BRN WMS';
const footer = markdown.match(/^<!--\s*footer:\s*(.+?)\s*-->\s*$/m)?.[1] ?? '';
const body = markdown
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
.replace(/^<!--\s*footer:.*?-->\s*$/m, '');
// The control block carries the document identity used by the header and footer.
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
const lines = afterTitle.split('\n');
const metadata = {};
const lines = body.split('\n');
let bodyStart = 0;
for (let index = 0; index < lines.length; index += 1) {
if (!isTableLine(lines[index])) continue;
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
if (!isControlBlock(table)) { index -= 1; continue; }
for (const cells of table.map(tableCells)) {
for (let i = 0; i < cells.length; i += 2) if (cells[i]) metadata[cells[i]] = cells[i + 1] ?? '';
const candidate = {};
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
for (const row of table.slice(dataStart).map(tableCells)) {
if (row.length >= 2) candidate[row[0]] = row[1];
}
break;
if (candidate.Document && candidate.Project) {
Object.assign(metadata, candidate);
bodyStart = index;
break;
}
index -= 1;
}
return { title, footer, metadata, body };
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
}
async function main() {
@@ -217,33 +131,27 @@ async function main() {
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
]);
const { title, footer, metadata, body } = extractDocument(markdown);
// The reference package prints every work product portrait except the
// evidence index and the summary traceability matrix.
const LANDSCAPE_TITLES = new Set(['List of Evidence', 'Traceability Record Table']);
const isLandscape = LANDSCAPE_TITLES.has(title);
const { title, metadata, body } = extractDocument(markdown);
const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
const values = {
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
documentTitle: title,
documentType: metadata['Document No'] ?? title,
ciBlue: '#0797d5',
ciGrey: '#8e8e8e', // ribbon grey measured from the audited reference documents
standard: 'ISO/IEC 29110-4-1:2018',
footerTag: footer,
release: metadata['Release, Version, By:'] ?? '',
projectName: metadata['Project Name'] ?? 'BRN WMS',
projectCode: metadata['Project Code'] ?? '200-WMS-26-001-00',
documentType: metadata.Document ?? title,
release: metadata.Release ?? '',
projectName: metadata.Project ?? 'BRN WMS',
projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
pageNumber: '<span class="pageNumber"></span>',
totalPages: '<span class="totalPages"></span>'
};
const pdfHeader = applyTemplate(headerTemplate, values);
const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
const pdfFooter = applyTemplate(footerTemplate, values);
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
const pageLayout = isLandscape
? '@page { size: A4 landscape; margin: 32mm 15mm 24mm 15mm; }'
? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
: '';
const templateFont = `<style>${fontFace}</style>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${markdownToHtml(body, path.dirname(source))}</main></body></html>`;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}</main></body></html>`;
await fs.mkdir(path.dirname(destination), { recursive: true });
const browser = await chromium.launch({ headless: true });
@@ -256,8 +164,8 @@ async function main() {
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: `${templateFont}${pdfHeader}`,
footerTemplate: `${templateFont}${pdfFooter}`
headerTemplate: '<div></div>',
footerTemplate: pdfFooter
});
} finally {
await browser.close();
-1
View File
@@ -1 +0,0 @@
pdf2docx>=0.5.8
+55 -103
View File
@@ -2,37 +2,53 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { inflateRawSync } from 'node:zlib';
const execFileAsync = promisify(execFile);
function isTableLine(line) {
return /^\|.*\|\s*$/.test(line.trim());
}
function tableCells(line) {
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
}
function isSeparator(cells) {
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
function extractDocumentContent(markdown) {
const titleMatch = markdown.match(/^#\s+.+\n+/m);
const body = markdown
.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0))
.replace(/^<!--\s*footer:.*?-->\s*$/m, '')
.replace(/<br\s*\/?>/gi, ' ') // markup, not document text
// A figure prints its caption; the image path is markup.
.replace(/^!\[([^\]]*)\]\([^)]*\)\s*$/gm, '$1')
// List markers are drawn by the renderer, not stored as text: the browser
// paints them from <ol>/<ul> and Word from numbering.xml, so they are
// markup here too and must not be expected in the extracted text.
.replace(/^[-*]\s+/gm, '')
.replace(/^\d+\.\s+/gm, '');
return { body, fields: {} };
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
const lines = afterTitle.split('\n');
for (let index = 0; index < lines.length; index += 1) {
if (!isTableLine(lines[index])) continue;
const table = [];
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
const fields = Object.fromEntries(table.slice(dataStart).map(tableCells).filter((row) => row.length >= 2));
if (fields.Document && fields.Project) return { body: lines.slice(index).join('\n'), fields };
index -= 1;
}
return { body: afterTitle, fields: {} };
}
function words(text) {
return new Set((text.normalize('NFC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []));
}
function compact(text) {
return text.normalize('NFC').toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
}
// Work products sit in the process folders; top-level files in sdlc/ are notes.
async function markdownFiles(directory, depth = 0) {
async function markdownFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const results = await Promise.all(entries.map(async (entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return markdownFiles(target, depth + 1);
return depth > 0 && entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
if (entry.isDirectory()) return markdownFiles(target);
return entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
}));
return results.flat();
}
@@ -42,98 +58,34 @@ async function pdfText(pdf) {
return stdout;
}
// A .docx is a zip; only one member is needed, so it is read here rather than
// taking a dependency for it. Entries are located through the central
// directory, then inflated from their own local header.
function readZipEntry(buffer, name) {
const endOfDirectory = buffer.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
if (endOfDirectory < 0) throw new Error('not a zip archive');
const entries = buffer.readUInt16LE(endOfDirectory + 10);
let cursor = buffer.readUInt32LE(endOfDirectory + 16);
for (let index = 0; index < entries; index += 1) {
if (buffer.readUInt32LE(cursor) !== 0x02014b50) throw new Error('damaged central directory');
const method = buffer.readUInt16LE(cursor + 10);
const compressedSize = buffer.readUInt32LE(cursor + 20);
const nameLength = buffer.readUInt16LE(cursor + 28);
const extraLength = buffer.readUInt16LE(cursor + 30);
const commentLength = buffer.readUInt16LE(cursor + 32);
const localOffset = buffer.readUInt32LE(cursor + 42);
const entryName = buffer.toString('utf8', cursor + 46, cursor + 46 + nameLength);
if (entryName === name) {
const localName = buffer.readUInt16LE(localOffset + 26);
const localExtra = buffer.readUInt16LE(localOffset + 28);
const start = localOffset + 30 + localName + localExtra;
const data = buffer.subarray(start, start + compressedSize);
return method === 0 ? Buffer.from(data) : inflateRawSync(data);
}
cursor += 46 + nameLength + extraLength + commentLength;
}
throw new Error(`${name} not found in archive`);
}
async function docxText(docx) {
const xml = readZipEntry(await fs.readFile(docx), 'word/document.xml').toString('utf8');
// Paragraph and row ends are text boundaries; only <w:t> carries characters.
return xml
.replace(/<\/w:p>|<\/w:tr>|<w:br[^>]*\/>|<w:tab[^>]*\/>/g, '\n')
.replace(/<w:t[^>]*>([^<]*)<\/w:t>/g, '$1')
.replace(/<[^>]+>/g, '')
.replaceAll('&amp;', '&').replaceAll('&lt;', '<').replaceAll('&gt;', '>')
.replaceAll('&quot;', '"').replaceAll('&apos;', "'");
}
const READERS = { pdf: pdfText, docx: docxText };
function tally(text) {
const counts = new Map();
for (const character of compact(text)) counts.set(character, (counts.get(character) ?? 0) + 1);
return counts;
}
async function main() {
const [sourceRoot, deliveryRoot, ...requested] = process.argv.slice(2);
if (!sourceRoot || !deliveryRoot) throw new Error('Usage: verify-content.mjs SOURCE_DIRECTORY DELIVERY_DIRECTORY [FORMAT…]');
const formats = requested.length ? requested : Object.keys(READERS);
const unknown = formats.filter((format) => !READERS[format]);
if (unknown.length) throw new Error(`Unknown format(s): ${unknown.join(', ')}`);
const [sourceRoot, deliveryRoot] = process.argv.slice(2);
if (!sourceRoot || !deliveryRoot) throw new Error('Usage: verify-content.mjs SOURCE_DIRECTORY DELIVERY_DIRECTORY');
const sources = (await markdownFiles(sourceRoot)).sort();
let failed = false;
const failures = [];
for (const format of formats) {
const failures = [];
for (const source of sources) {
const relative = path.relative(sourceRoot, source);
const rendered = path.join(deliveryRoot, relative.replace(/\.md$/, `.${format}`));
const [markdown, extracted] = await Promise.all([
fs.readFile(source, 'utf8'),
READERS[format](rendered)
]);
const { body } = extractDocumentContent(markdown);
// Thai has no word spaces, so a run can wrap several times inside a narrow
// table cell and reach the extracted text as separate fragments. Coverage is
// therefore checked by character count: every character of the source must
// appear in the rendering at least as many times. Clipped or dropped text
// loses glyphs and still fails; re-wrapping does not.
const counts = tally(extracted);
const missing = [...tally(body)]
.filter(([character, count]) => (counts.get(character) ?? 0) < count)
.map(([character, count]) => `${character}×${count - (counts.get(character) ?? 0)}`);
if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`);
}
if (failures.length) {
failed = true;
console.error(`Content coverage failed for ${failures.length}/${sources.length} ${format.toUpperCase()}s:`);
failures.forEach((failure) => console.error(`- ${failure}`));
} else {
console.log(`Content coverage passed: ${sources.length}/${sources.length} ${format.toUpperCase()}s.`);
}
for (const source of sources) {
const relative = path.relative(sourceRoot, source);
const pdf = path.join(deliveryRoot, relative.replace(/\.md$/, '.pdf'));
const [markdown, extracted] = await Promise.all([fs.readFile(source, 'utf8'), pdfText(pdf)]);
const { body, fields } = extractDocumentContent(markdown);
const controlText = ['Document', 'Project', 'Project code', 'Project period', 'Release']
.map((field) => fields[field] ?? '')
.join('\n');
const expected = words(`${body}\n${controlText}`);
const actual = compact(extracted);
const missing = [...expected].filter((word) => !actual.includes(word));
if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`);
}
if (failed) process.exit(1);
if (failures.length) {
console.error(`Content coverage failed for ${failures.length}/${sources.length} PDFs:`);
failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
}
console.log(`Content coverage passed: ${sources.length}/${sources.length} PDFs.`);
}
main().catch((error) => { console.error(error); process.exit(1); });
-58
View File
@@ -1,58 +0,0 @@
# SDLC work-product seed
Regenerates every ISO/IEC 29110 work product under `sdlc/` from controlled project
data, in the layout of the audited `200-TAS-25-001-00` reference package: Thai
section headings, a `Document No` control block, and Secretary / Reviewer /
Approval signature tables on every document.
`sdlc/` is generated output. Edit the data files here, never the Markdown.
## Files
| File | Contents |
|---|---|
| `project.mjs` | People, dates, project code, scope, deliverables, Customer Requirements (CR01–CR14) |
| `engineering.mjs` | Software Requirements (SR01–SR09), Software Units, Test Cases, and the CR→SR→Unit→Test mapping |
| `history.mjs` | Work Schedule, reporting periods, meetings, Correction Register, Change Reports |
| `lib.mjs` | Buddhist-calendar dates, Markdown tables, signature blocks |
| `format.mjs` | The shared work-product scaffold |
| `figures.mjs` | Software Design diagrams and wireframes, drawn as SVG; `scripts/sdlc-delivery/render-figures.mjs` rasterises them into `figures/*.png` |
| `docs-*.mjs` | One generator per work product |
| `timeline.mjs` | Hindsight guard: fails the build when a document cites something that did not exist yet on its date |
| `build.mjs` | Writes all documents and checks no folder is left empty |
## Usage
```bash
node scripts/sdlc-seed/build.mjs # regenerate sdlc/ only
bash scripts/build-sdlc-delivery.sh # seed, render to PDF and Word, then verify
```
The traceability chain is validated at import time: every Customer Requirement
must resolve to existing Software Requirements, Software Units, and a Test Case,
or the build fails.
## Write each document as of its own date
Every work product is dated (`... 25690105 V1.0 ...`) and may only state what was
known on that day. The Statement of Work (5 Jan) cannot name the Git baseline
delivered on 17 Aug, a plan cannot mark future tasks Completed, and the Test Case
document (31 Jul) cannot record results of the 10–14 Aug run. Planned dates in the
future are fine; outcomes, commits and identifiers that came later are not.
`build.mjs` enforces the checkable part through `timeline.mjs` and stops with a
list of offending documents when a work product cites:
- a Git commit created after the document date or after the project end
(`END`), or one that is not on branch `main` (dates read from `git log`);
- any branch other than `main` (the documents describe the delivered software,
which lives on `main` only), or a document dated after `END`;
- the delivery tag `TAG` before the tag was created;
- an `ISS-nnn` before its detection date in the Correction Register;
- CR, SR, Unit or Test Case IDs before the work product that defines them.
Prose is not checked automatically. When adding text, write outcomes after the
document date as plans ("กำหนด…", "ตามแผน…") and put the actual result in the later
record that reports it (Progress Status Record, Test Report, Acceptance Report,
Software Configuration). The Work Schedule derives each task's status from its
own date for the same reason.

Some files were not shown because too many files have changed in this diff Show More