remove actions and data integrity

This commit is contained in:
Thanakorn S
2026-04-24 14:25:39 +07:00
parent a75e180686
commit 92d116fd21
26 changed files with 1325 additions and 249 deletions
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* ContactManager
*
* Encapsulates contact and contact type operations:
* - Soft-delete with downstream validation
*
* Note: Methods that modify data do NOT manage their own DB transactions.
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
*/
class ContactManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Check if a contact_id is referenced in any active td_stock_* row.
* Returns true if blocking stock exists.
*/
private function hasActiveStock(int $contact_id): bool {
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `$table`
WHERE company_id = :company_id
AND contact_id = :contact_id"
);
$sth->execute([
':company_id' => $this->company_id,
':contact_id' => $contact_id,
]);
if ($sth->fetchColumn() > 0) {
return true;
}
}
return false;
}
/**
* Build a log entry array for audit context.
*/
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
// ─────────────────────────────────────────────────────────────
// Contact type
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a contact type.
*
* Blocks if any md_contact references this type.
*
* @throws Exception if contact type not found or has dependent contacts
*/
public function deleteContactType(int $type_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_type, `log` FROM md_contact_type
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $type_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact type not found.");
}
// Block if any contact references this type
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_contact
WHERE company_id = :company_id
AND contact_type = :type_id"
);
$sth->execute([
':company_id' => $this->company_id,
':type_id' => $type_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — contact type \"{$row['contact_type']}\" " .
"still has contacts assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_contact_type
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $type_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Contact
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a contact.
*
* Blocks if the contact is referenced in any active td_stock_* row.
*
* @throws Exception if contact not found or referenced in active stock
*/
public function deleteContact(int $contact_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_name, `log` FROM md_contact
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $contact_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact not found.");
}
// Block if contact is referenced in any active stock transaction
if ($this->hasActiveStock($contact_id)) {
throw new Exception(
"Cannot delete — \"{$row['contact_name']}\" " .
"is referenced in active stock transactions."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_contact
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $contact_id,
':company_id' => $this->company_id,
]);
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
/**
* ProductManager
*
* Encapsulates product and product category operations:
* - Soft-delete with downstream validation
*
* Note: Methods that modify data do NOT manage their own DB transactions.
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
*/
class ProductManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Check if a SKU has any active stock across all td_stock_* tables.
* Returns the first blocking table name found, or null if clear.
*/
private function findActiveStock(string $sku): ?string {
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `$table`
WHERE company_id = :company_id
AND product_sku = :sku"
);
$sth->execute([
':company_id' => $this->company_id,
':sku' => $sku,
]);
if ($sth->fetchColumn() > 0) {
return $table;
}
}
return null;
}
/**
* Build a log entry array for audit context.
*/
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
// ─────────────────────────────────────────────────────────────
// Product category
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a product category.
*
* Blocks if any md_product references this category.
*
* @throws Exception if category not found or has dependent products
*/
public function deleteCategory(int $category_id): void {
$sth = $this->pdo->prepare(
"SELECT id, category, `log` FROM md_product_category
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $category_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product category not found.");
}
// Block if any product references this category
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_product
WHERE company_id = :company_id
AND category = :category_id"
);
$sth->execute([
':company_id' => $this->company_id,
':category_id' => $category_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — category \"{$row['category']}\" " .
"still has products assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_product_category
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $category_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Product
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a product.
*
* Blocks if the product SKU has any active stock across any td_stock_* table.
*
* @throws Exception if product not found or has active stock
*/
public function deleteProduct(int $product_id): void {
$sth = $this->pdo->prepare(
"SELECT id, product_name, sku, `log` FROM md_product
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $product_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product not found.");
}
// Block if active stock exists for this SKU in any warehouse
$blocking_table = $this->findActiveStock($row['sku']);
if ($blocking_table !== null) {
throw new Exception(
"Cannot delete — \"{$row['product_name']}\" " .
"still has active stock in the system."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_product
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $product_id,
':company_id' => $this->company_id,
]);
}
}
+475 -70
View File
@@ -125,8 +125,22 @@ class WarehouseManager {
*/
public function syncRacks(int $storage_id, array $range): void {
// Guard: refuse if any rack under this storage_id is still occupied
$this->validateRangeChange($storage_id, $range);
$this->removeRacksOutsideRange($storage_id, $range);
// Wipe all empty racks belonging to this storage_id
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NULL";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
// Reinsert the full range fresh
$this->insertRacksInRange($storage_id, $range);
}
@@ -161,32 +175,6 @@ class WarehouseManager {
}
}
/**
* Delete empty racks that fall outside the new range.
*/
private function removeRacksOutsideRange(int $storage_id, array $range): void {
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND (
CAST(aisle AS UNSIGNED) < :aisle_from OR
CAST(aisle AS UNSIGNED) > :aisle_to OR
CAST(rack AS UNSIGNED) < :rack_from OR
CAST(rack AS UNSIGNED) > :rack_to
)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":storage_id" => $storage_id,
":aisle_from" => (int) $range['aisle_from'],
":aisle_to" => (int) $range['aisle_to'],
":rack_from" => (int) $range['rack_from'],
":rack_to" => (int) $range['rack_to'],
]);
}
/**
* Insert rack rows for the full range (IGNORE skips duplicates).
*/
@@ -266,24 +254,30 @@ class WarehouseManager {
// ─────────────────────────────────────────────────────────────
/**
* Build a log JSON by appending an action entry to an existing md_rack.log.
* Entry captures user_id, dt (now), and login (session start) for audit context,
* Insert a log entry into md_rack_log for the given rack and action.
* Captures user_id, dt (now), and login (session start) for audit context,
* plus any action-specific fields passed via $extra.
*/
private function buildRackLog(?string $existing_log, string $action, array $extra = []): string {
private function insertRackLog(int $rack_id, string $action, array $extra = []): void {
$log = json_decode($existing_log ?: '[]', true) ?: [];
$sql = "INSERT INTO md_rack_log
(company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id)
VALUES
(:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)";
$log[] = array_merge([
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
], $extra);
return json_encode($log, JSON_UNESCAPED_UNICODE);
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':md_rack_id' => $rack_id,
':user_id' => $_SESSION['login_user_id'] ?? null,
':dt' => date('Y-m-d H:i:s'),
':login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
':action' => $action,
':product_sku' => $extra['product_sku'] ?? null,
':td_stock_id' => $extra['td_stock_id'] ?? null,
]);
}
/**
@@ -296,8 +290,8 @@ class WarehouseManager {
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
// Lock and fetch current state (including log for in-place append)
$sql = "SELECT id, product_sku, `log` FROM md_rack
$sql = "SELECT id, product_sku FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
@@ -328,20 +322,20 @@ class WarehouseManager {
// Single atomic UPDATE: state + log together
$sql = "UPDATE md_rack
SET product_sku = :product_sku,
td_stock_id = :td_stock_id,
`log` = :log
td_stock_id = :td_stock_id
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":product_sku" => $product_sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($current['log'], 'occupy', [
'product_sku' => $product_sku,
'td_stock_id' => $td_stock_id,
]),
":id" => $current['id'],
]);
$this->insertRackLog($current['id'], 'occupy', [
'product_sku' => $product_sku,
'td_stock_id' => $td_stock_id,
]);
}
/**
@@ -349,8 +343,8 @@ class WarehouseManager {
*/
public function releaseRack($warehouse_id, $zone, $aisle, $rack): void {
// Fetch current state + log (needed to append release event)
$sql = "SELECT id, product_sku, td_stock_id, `log` FROM md_rack
$sql = "SELECT id, product_sku, td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
@@ -376,18 +370,18 @@ class WarehouseManager {
// Clear state + record log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL,
td_stock_id = NULL,
`log` = :log
td_stock_id = NULL
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":log" => $this->buildRackLog($current['log'], 'release', [
'product_sku' => $current['product_sku'],
'td_stock_id' => $current['td_stock_id'],
]),
":id" => $current['id'],
]);
$this->insertRackLog($current['id'], 'release', [
'product_sku' => $current['product_sku'],
'td_stock_id' => $current['td_stock_id'],
]);
}
/**
@@ -403,7 +397,7 @@ class WarehouseManager {
): void {
// Fetch both racks with FOR UPDATE, ordered by id to prevent deadlocks
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id, `log`
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id
FROM md_rack
WHERE company_id = :company_id
AND (
@@ -464,32 +458,443 @@ class WarehouseManager {
// Clear source — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL, td_stock_id = NULL, `log` = :log
SET product_sku = NULL, td_stock_id = NULL
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":log" => $this->buildRackLog($from['log'], 'transfer_out', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'to_rack_id' => $to['id'],
]),
":id" => $from['id'],
]);
$this->insertRackLog($from['id'], 'transfer_out', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
]);
// Populate destination — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = :sku, td_stock_id = :td_stock_id, `log` = :log
SET product_sku = :sku, td_stock_id = :td_stock_id
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($to['log'], 'transfer_in', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'from_rack_id' => $from['id'],
]),
":id" => $to['id'],
]);
$this->insertRackLog($to['id'], 'transfer_in', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Delete (soft-delete) operations
// ─────────────────────────────────────────────────────────────
/**
* Validate that a given row is the latest transaction for its SKU in the table.
* Throws a descriptive exception if a newer transaction exists.
*
* @param string $product_sku SKU to check
* @param string $row_date The date of the row being deleted
* @param string $product_name Human-readable product name for error message
* @throws Exception if a newer transaction exists
*/
private function validateLatestTransaction(
string $product_sku,
string $row_date,
string $product_name
): void {
// Discover all td_stock_* tables for this database
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
if (empty($tables)) {
return;
}
// Build UNION across all td_stock_* tables to find global MAX(date)
$unions = implode(' UNION ALL ', array_map(
fn($t) => "SELECT `type`, `date` FROM `$t`
WHERE company_id = :company_id
AND product_sku = :product_sku",
$tables
));
$sth = $this->pdo->prepare(
"SELECT `type`, `date` FROM ($unions) AS all_stock
ORDER BY `date` DESC
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $product_sku,
]);
$latest = $sth->fetch(PDO::FETCH_ASSOC);
if (!$latest || $row_date === $latest['date']) {
return; // ✓ this is the latest globally — allow
}
$type = strtoupper($latest['type']);
$date = $latest['date'];
throw new Exception(
"Cannot delete — \"{$product_name}\" has a newer " .
"{$type} transaction on {$date} that must be deleted first."
);
}
/**
* Helper to build a log entry with consistent user and timestamp info for warehouse operations.
*/
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
/**
* Resolve product name from md_product for readable error messages.
*/
private function getProductName(string $product_sku): string {
$sth = $this->pdo->prepare(
"SELECT product_name FROM md_product
WHERE company_id = :company_id AND sku = :sku"
);
$sth->execute([
':company_id' => $this->company_id,
':sku' => $product_sku,
]);
return $sth->fetchColumn() ?: $product_sku;
}
/**
* Soft-delete a stock_in record and reverse its side effects.
*
* - Validates this is the latest transaction for the SKU
* - Negates company_id (soft-delete)
* - Releases the rack
* - Reverses the balance
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction or record not found
*/
public function deleteStockIn(int $stock_id, int $warehouse_id): void {
$ctx = $this->getStockContext($warehouse_id, $stock_id);
$table = $ctx['table'];
$row = $ctx['row'];
if (!$row) {
throw new Exception("Stock in record not found.");
}
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete
$this->pdo->prepare(
"UPDATE `$table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Release the rack
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
// Reverse balance
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
}
/**
* Soft-delete a stock_out record and reverse its side effects.
*
* - Validates this is the latest transaction for the SKU
* - Negates company_id (soft-delete)
* - Re-occupies the rack with the original stock_in batch (via ref_id)
* - Reverses the balance
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction or record not found
*/
public function deleteStockOut(int $stock_id, int $warehouse_id): void {
$ctx = $this->getStockContext($warehouse_id, $stock_id);
$table = $ctx['table'];
$row = $ctx['row'];
if (!$row) {
throw new Exception("Stock out record not found.");
}
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete
$this->pdo->prepare(
"UPDATE `$table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Re-occupy the rack — restore the stock_in batch that was consumed
$this->occupyRack(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
$product_sku,
(int)$row['ref_id']
);
// Reverse balance
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
}
/**
* Soft-delete a transfer record pair and reverse all side effects.
*
* - Validates this is the latest transaction for the SKU on BOTH warehouses
* - Negates company_id on both rows (soft-delete)
* - Releases destination rack, re-occupies source rack
* - Reverses balances on both warehouses
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction on either side, or records not found
*/
public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void {
// Fetch the "from" row (transfer_out side)
$from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id);
$from_table = $from_ctx['table'];
$from_row = $from_ctx['row'];
if (!$from_row || $from_row['type'] !== 'transfer') {
throw new Exception("Transfer record not found.");
}
$product_sku = $from_row['product_sku'];
$product_name = $this->getProductName($product_sku);
$to_warehouse = (int)$from_row['ref_warehouse'];
$ref_id = (int)$from_row['ref_id'];
// Fetch the "to" row (transfer_in side)
$to_ctx = $this->getStockContext($to_warehouse, $ref_id);
$to_row = $to_ctx['row'];
if (!$to_row) {
throw new Exception("Paired destination record missing — data integrity issue.");
}
// Single global validation covers both warehouses
$this->validateLatestTransaction($product_sku, $from_row['date'], $product_name);
// Soft-delete both rows
$this->pdo->prepare(
"UPDATE `$from_table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]);
$this->pdo->prepare(
"UPDATE `{$to_ctx['table']}`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
// Destination rack was occupied by transfer_in — release it
$this->releaseRack(
$to_warehouse,
$to_row['zone'], $to_row['aisle'], $to_row['rack']
);
// Source rack was released by transfer_out — re-occupy with original batch
$this->occupyRack(
$from_warehouse_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
$product_sku,
$from_stock_id
);
// Reverse balances
$quantity = (int)$from_row['out'];
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
}
/**
* Soft-delete a warehouse.
*
* Blocks if any md_storage references this warehouse by name.
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if warehouse not found or has dependent storage
*/
public function deleteWarehouse(int $warehouse_id): void {
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name, `log` FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $warehouse_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Warehouse not found.");
}
// Block if any md_storage references this warehouse by name
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_storage
WHERE company_id = :company_id
AND warehouse = :warehouse_name"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_name' => $row['warehouse_name'],
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
"still has storage locations assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_warehouse
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $warehouse_id,
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a storage record and its associated racks.
*
* - Blocks if any md_rack under this storage_id is occupied
* - Hard-deletes md_rack_log rows for those racks
* - Soft-deletes all md_rack rows under this storage_id
* - Soft-deletes md_storage
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if storage not found or has occupied racks
*/
public function deleteStorage(int $storage_id): void {
$sth = $this->pdo->prepare(
"SELECT id, zone, `log` FROM md_storage
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $storage_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Storage not found.");
}
// Block if any rack under this storage is occupied
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — storage zone \"{$row['zone']}\" " .
"still has occupied racks with stock."
);
}
// Collect rack IDs before deleting — 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 md_rack_log for these racks
if (!empty($rack_ids)) {
$placeholders = implode(',', array_fill(0, count($rack_ids), '?'));
$this->pdo->prepare(
"DELETE FROM md_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
SET company_id = company_id * -1
WHERE company_id = :company_id
AND storage_id = :storage_id"
)->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete md_storage
$this->pdo->prepare(
"UPDATE md_storage
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $storage_id,
':company_id' => $this->company_id,
]);
}
}
?>