From dfeb57533a45e639124322a0601fcf173f13c0b0 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Tue, 26 May 2026 17:33:58 +0700 Subject: [PATCH] Master data review: spec updates and C1/C2/M3-M6 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec (docs/reviewing/master-data.md): - M1: Remove margin from stored product fields (it's a derived frontend value) - M2: Clarify posting window only restricts transaction dates, not master data - M5: Document AccountFormulaManager::delete() as archive, not physical delete Code: - C1: Fix CompanySettingManager property typo (company_id → companyId) — prevented PHP 8.4 dynamic property fatal on all posting window operations - C2: Fix WarehouseManager::deleteWarehouse() guard — was comparing warehouse_name (string) against warehouse id column (no-op); now correctly blocks on storage rows and active stock rows - M3: Remove hard-delete of td_rack_log in deleteStorage() — retain rack history consistent with soft-delete philosophy elsewhere - M4: Add reference guards to ChartOfAccounts::delete() (blocks on GL items, formula items, product account mappings) and DepartmentManager::delete() (blocks on GL items) - M6: Fix CompanySettingManager::handle() partial update — only upsert keys present in the request, not all allowed keys defaulted Co-Authored-By: Claude Sonnet 4.6 --- .../utils/classes/CompanySettingManager.php | 6 +- app/assets/utils/classes/WarehouseManager.php | 51 +++--- .../utils/classes_ac/ChartOfAccounts.php | 39 ++++- .../utils/classes_ac/DepartmentManager.php | 11 +- docs/reviewing/master-data.md | 158 ++++++++++++++++++ 5 files changed, 230 insertions(+), 35 deletions(-) create mode 100644 docs/reviewing/master-data.md diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php index fc64e3d..6a836d8 100644 --- a/app/assets/utils/classes/CompanySettingManager.php +++ b/app/assets/utils/classes/CompanySettingManager.php @@ -19,7 +19,7 @@ class CompanySettingManager { private PDO $pdo; private ?PDO $transactionPdo; - private int $company_id; + private int $companyId; // ── Known keys with their defaults ─────────────────────────────────────── private const DEFAULTS = [ @@ -316,7 +316,9 @@ class CompanySettingManager $allowed = array_keys(self::DEFAULTS); $incoming = []; foreach ($allowed as $key) { - $incoming[$key] = $data[$key] ?? self::DEFAULTS[$key]; + if (array_key_exists($key, $data)) { + $incoming[$key] = $data[$key]; + } } $blocked = $this->blockedChanges($incoming); diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index c242378..33e5d8c 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -711,24 +711,37 @@ class WarehouseManager { throw new Exception("Warehouse not found."); } - // Block if any md_storage references this warehouse by name + // Block if any md_storage references this warehouse $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_storage - WHERE company_id = :company_id - AND warehouse = :warehouse_name" + WHERE company_id = :company_id + AND warehouse = :warehouse_id" ); $sth->execute([ - ':company_id' => $this->company_id, - ':warehouse_name' => $row['warehouse_name'], + ':company_id' => $this->company_id, + ':warehouse_id' => $warehouse_id, ]); - - if ($sth->fetchColumn() > 0) { + if ((int)$sth->fetchColumn() > 0) { throw new Exception( "Cannot delete — warehouse \"{$row['warehouse_name']}\" " . "still has storage locations assigned to it." ); } + // Block if the warehouse's stock table has any rows + $stock_table = $this->stockTableNameFromWarehouseId($warehouse_id); + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM `{$stock_table}` + WHERE company_id = :company_id" + ); + $sth->execute([':company_id' => $this->company_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot delete — warehouse \"{$row['warehouse_name']}\" " . + "still has stock records. Clear the stock first." + ); + } + // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); @@ -906,8 +919,8 @@ class WarehouseManager { * Soft-delete a storage record and its associated rack rows. * * Blocks deletion if any rack under this storage_id is occupied. - * Also hard-deletes td_rack_log entries for those racks before soft-deleting - * the md_rack rows themselves, then soft-deletes md_storage. + * Soft-deletes md_rack rows (company_id negation) and retains td_rack_log + * for audit purposes. Then soft-deletes md_storage. * * Must be called inside dbTransaction() by the caller. * @@ -949,26 +962,6 @@ class WarehouseManager { ); } - // Collect rack IDs before deletion — needed for rack_log cleanup - $sth = $this->pdo->prepare( - "SELECT id FROM md_rack - WHERE company_id = :company_id - AND storage_id = :storage_id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':storage_id' => $storage_id, - ]); - $rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN); - - // Hard-delete td_rack_log for these racks (log records have no independent value) - if (!empty($rack_ids)) { - $placeholders = implode(',', array_fill(0, count($rack_ids), '?')); - $this->pdo->prepare( - "DELETE FROM td_rack_log WHERE md_rack_id IN ($placeholders)" - )->execute($rack_ids); - } - // Soft-delete all md_rack rows under this storage $this->pdo->prepare( "UPDATE md_rack diff --git a/app/assets/utils/classes_ac/ChartOfAccounts.php b/app/assets/utils/classes_ac/ChartOfAccounts.php index b34c55d..75de5e7 100644 --- a/app/assets/utils/classes_ac/ChartOfAccounts.php +++ b/app/assets/utils/classes_ac/ChartOfAccounts.php @@ -106,10 +106,45 @@ class ChartOfAccounts public function delete(int $id): void { $sth = $this->pdo->prepare( - "UPDATE md_account SET status = 0 - WHERE company_id = :cid AND id = :id" + "SELECT account_code FROM md_account + WHERE company_id = :cid AND id = :id LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if (!$row) throw new Exception('Account not found.'); + $code = $row['account_code']; + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_gl_item WHERE account_code = :code LIMIT 1" + ); + $sth->execute([':code' => $code]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot deactivate — account {$code} has GL journal entries."); + } + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_account_formula_item + WHERE company_id = :cid AND account_code = :code LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':code' => $code]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot deactivate — account {$code} is used in one or more account formulas."); + } + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_product + WHERE company_id = :cid + AND (sales_account_code = :code OR purchase_account_code = :code) LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':code' => $code]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot deactivate — account {$code} is mapped to one or more products."); + } + + $this->pdo->prepare( + "UPDATE md_account SET status = 0 + WHERE company_id = :cid AND id = :id" + )->execute([':cid' => $this->companyId, ':id' => $id]); } public function isPostingAccount(string $account_code): bool diff --git a/app/assets/utils/classes_ac/DepartmentManager.php b/app/assets/utils/classes_ac/DepartmentManager.php index bc8924e..f849914 100644 --- a/app/assets/utils/classes_ac/DepartmentManager.php +++ b/app/assets/utils/classes_ac/DepartmentManager.php @@ -71,10 +71,17 @@ class DepartmentManager public function delete(int $id): void { $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_gl_item WHERE department_id = :id LIMIT 1" + ); + $sth->execute([':id' => $id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception('Cannot deactivate — this department has GL journal entries.'); + } + + $this->pdo->prepare( "UPDATE md_department SET status = 0 WHERE company_id = :cid AND id = :id" - ); - $sth->execute([':cid' => $this->companyId, ':id' => $id]); + )->execute([':cid' => $this->companyId, ':id' => $id]); } public function getStats(): array diff --git a/docs/reviewing/master-data.md b/docs/reviewing/master-data.md new file mode 100644 index 0000000..10f3f46 --- /dev/null +++ b/docs/reviewing/master-data.md @@ -0,0 +1,158 @@ +# Master Data Features + +## Products + +Routes: + +- `app/inventory/product.php` +- `app/inventory/manage_product.php` +- `app/inventory/manage_category.php` +- `app/inventory/api/engine/product.php` +- `app/inventory/api/engine/manage_product.php` +- `app/inventory/api/engine/product_category.php` + +Products store SKU, product name, barcode, UOM, price, cost price, min stock, reorder point, category, image, status, and GL mapping fields. Margin is a derived display value (price − cost price) computed on the frontend and is not stored. + +Product accounting fields: + +- `sales_account_code` +- `purchase_account_code` + +These fields support Product Account Mapping for GL posting. + +Accounting users can maintain only these two GL mapping fields from `app/accounting/account_formulas.php` without opening the full product edit page. + +Backend support: + +- `ProductManager::getProductList()` +- `ProductManager::saveProduct()` +- `ProductManager::deleteProduct()` +- `ProductManager::updateAccountMapping()` + +## Product Categories + +Routes: + +- `app/inventory/manage_category.php` +- `app/inventory/api/engine/manage_category.php` +- `app/inventory/api/engine/product_category.php` + +Product categories organize products and are protected from deletion when active products or stock depend on them. + +Backend support: + +- `ProductManager::getCategoryList()` +- `ProductManager::saveCategory()` +- `ProductManager::deleteCategory()` + +## Warehouse Locations + +Routes: + +- `app/inventory/warehouse.php` +- `app/inventory/manage_warehouse.php` +- `app/inventory/manage_storage.php` +- `app/inventory/api/engine/warehouse.php` +- `app/inventory/api/engine/manage_warehouse.php` +- `app/inventory/api/engine/storage.php` +- `app/inventory/api/engine/manage_storage.php` + +Warehouse setup manages warehouses and storage hierarchy. WMS forms use warehouse, zone, aisle, and rack data for stock movement, barcode labels, validation, and capacity reporting. + +Backend support: + +- `WarehouseManager` +- `ReportManager` capacity and occupancy methods + +## Contacts + +Routes: + +- `app/contact/contact.php` +- `app/contact/manage_contact.php` +- `app/contact/manage_contact_type.php` +- `app/contact/api/engine/contact.php` +- `app/contact/api/engine/manage_contact.php` +- `app/contact/api/engine/contact_type.php` + +Contacts represent customers, suppliers, or other counterparties. Contact records support type/category, image upload, status, search, and transaction references from sales, purchase, receipts, and payments. + +Backend support: + +- `ContactManager::getContactList()` +- `ContactManager::searchContact()` +- `ContactManager::saveContact()` +- `ContactManager::deleteContact()` +- `ContactManager::saveContactType()` + +## Chart Of Accounts + +Routes: + +- `app/accounting/chart_of_accounts.php` +- `app/accounting/manage_account.php` + +Chart of Accounts is master data for accounting. Each account has code, name, type, category, parent, posting flag, and status. + +Important account categories: + +- `sales_tax` +- `purchase_tax` + +These categories feed VAT reporting. + +Backend support: + +- `ChartOfAccounts` + +## Departments + +Routes: + +- `app/accounting/departments.php` +- `app/accounting/manage_department.php` + +Departments are accounting dimensions used by formulas, journals, and reports. They can be active or inactive. + +Backend support: + +- `DepartmentManager` + +## Account Formulas + +Routes: + +- `app/accounting/account_formulas.php` + +Account Formulas are master setup for automated GL posting. They are maintained separately from actual journal entries. + +`AccountFormulaManager::delete()` is an **archive operation**, not a physical delete. It sets `status = 0` and clears the `is_default` flag on `md_account_formula`. The formula row and its line items remain in the database. Historical GL entries in `td_gl` that reference the formula via `formula_id` continue to point to the archived row — this is intentional, as the snapshot is preserved for audit and re-post replay. An archived formula can no longer be selected for new postings (`isPostingAccount()` requires `status = 1`) but existing GL history is unaffected. + +Formula setup includes: + +- Document type. +- Formula name. +- Default flag. +- Status. +- Debit/credit formula lines. +- Amount key per line. +- Posting account per line. + +Backend support: + +- `AccountFormulaManager` + +## Posting Window + +Routes: + +- `app/accounting/posting_window.php` + +Posting Window is master configuration for accounting/inventory date control. It can define no restriction, a lower date, an upper date, or a bounded posting period. + +The posting window restricts **transaction document dates** only — GL postings, stock movements, receipts, payments, and invoice issuance. It does **not** restrict saves or deletes of master data records (products, contacts, warehouses, chart of accounts, departments). Master data edits are always permitted regardless of the posting window. + +Backend support: + +- `CompanySettingManager` — stores and retrieves the window bounds as company settings (`posting_open_from`, `posting_open_to`) +- `PostingWindowGuard` — enforces the window; called by `GlManager` and `WarehouseManager` stock-movement paths, not by master-data managers