Master data review: spec updates and C1/C2/M3-M6 fixes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
AND warehouse = :warehouse_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_name' => $row['warehouse_name'],
|
||||
':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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user