Files

776 lines
30 KiB
PHP

<?php
/**
* BarcodeManager
*
* Centralises all md_barcode operations: creating, printing, enabling/disabling,
* and resolving SKU and location barcode labels.
*
* Method order:
* Private helpers → product lookup, lot resolution, label fetch, location validation
* SKU label basis → create / list for product (SKU) barcodes
* Location label → create / list for warehouse location barcodes
* Shared operations → recordPrint / setStatus (both types)
* Lookup basis → resolve any barcode string to its type and fields
* Dropdown basis → lot and product lists for label creation UI
*
* Note: operates entirely on $pdo2 (the client database, wms2).
* The $advancedLocation flag needed by lookup() must be resolved by the
* caller via CompanySettingManager($pdo1) — keeps this class scoped to wms2.
*
* Security: all SQL uses PDO prepared statements with bound parameters.
* No user input is ever interpolated directly into a query string.
*/
class BarcodeManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
private function stockTableName(int $warehouse_id): string {
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
if (!$sth->fetchColumn()) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
return 'td_stock_' . $warehouse_id;
}
/**
* Fetch a single active product row by SKU.
*/
private function findProduct(string $sku): array|false {
$sth = $this->pdo->prepare(
"SELECT sku, product_name, uom
FROM md_product
WHERE company_id = :company_id
AND sku = :sku
AND status > 0
LIMIT 1"
);
$sth->execute([':company_id' => $this->company_id, ':sku' => $sku]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* When a serial is provided, verify it does not already map to a different lot
* across any active warehouse stock table.
*
* Throws if the serial is claimed by a different lot, otherwise returns silently.
*/
private function validateLotSerial(string $sku, string $lot, string $serial): void {
if ($serial === '') return;
$warehouses = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$warehouses->execute([':company_id' => $this->company_id]);
foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) {
$table = 'td_stock_' . (int)$warehouse_id;
$sth = $this->pdo->prepare(
"SELECT lot_number
FROM `{$table}`
WHERE company_id = :company_id
AND product_sku = :sku
AND serial_number = :serial
AND status != -1
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':sku' => $sku,
':serial' => $serial,
]);
$existing_lot = $sth->fetchColumn();
if ($existing_lot !== false && (string)$existing_lot !== $lot) {
throw new Exception('Serial number belongs to a different lot.');
}
}
}
/**
* Fetch a barcode row from md_barcode by barcode string and type.
*
* Returns false if the row does not exist.
* Throws if the row exists but is disabled (status != 1).
*/
private function findPreparedLabel(string $barcode, string $type): array|false {
$sth = $this->pdo->prepare(
"SELECT *
FROM md_barcode
WHERE company_id = :company_id
AND barcode = :barcode
AND barcode_type = :barcode_type
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':barcode_type' => $type,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row && (int)$row['status'] !== 1) {
throw new Exception('Barcode is disabled.');
}
return $row ?: false;
}
/**
* Confirm a bin row exists for the given warehouse + zone + aisle + bin.
* Throws if not found.
*/
private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $bin): void {
$sth = $this->pdo->prepare(
"SELECT 1 FROM md_bin
WHERE company_id = :company_id
AND warehouse = :warehouse
AND COALESCE(zone, '') = :zone
AND COALESCE(aisle,'') = :aisle
AND bin = :bin
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':bin' => $bin,
]);
if (!$sth->fetchColumn()) throw new Exception('Location not found.');
}
/**
* Check whether any warehouse stock table has a positive balance for a given SKU + lot.
* Used as a fallback when a lot row is missing from md_lot.
*/
private function activeStockLotExists(string $sku, string $lot): bool {
$warehouses = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$warehouses->execute([':company_id' => $this->company_id]);
foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) {
$table = 'td_stock_' . (int)$warehouse_id;
$sth = $this->pdo->prepare(
"SELECT ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS balance
FROM `{$table}`
WHERE company_id = :company_id
AND product_sku = :product_sku
AND lot_number = :lot_number
AND status = 1"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $sku,
':lot_number' => $lot,
]);
if ((float)$sth->fetchColumn() > 0) return true;
}
return false;
}
/**
* Look up a lot by SKU + lot number.
*
* Tries md_lot first; falls back to synthesising a row from active stock
* (handles the case where stock was entered without an md_lot record).
* Returns false if neither source yields a result.
*/
private function findLot(string $sku, string $lot): array|false {
$sth = $this->pdo->prepare(
"SELECT
l.product_sku,
l.lot_number,
l.expiry_date,
p.product_name,
p.uom
FROM md_lot l
INNER JOIN md_product p
ON p.company_id = l.company_id
AND p.sku = l.product_sku
WHERE l.company_id = :company_id
AND l.product_sku = :product_sku
AND l.lot_number = :lot_number
AND p.status > 0
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $sku,
':lot_number' => $lot,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row) return $row;
$product = $this->findProduct($sku);
if (!$product || !$this->activeStockLotExists($sku, $lot)) return false;
return [
'product_sku' => $product['sku'],
'lot_number' => $lot,
'expiry_date' => null,
'product_name' => $product['product_name'],
'uom' => $product['uom'],
];
}
// ─────────────────────────────────────────────────────────────
// SKU LABEL BASIS
// ─────────────────────────────────────────────────────────────
/**
* Return all SKU barcode labels, optionally filtered by SKU and/or lot.
*
* Joined to md_product for product_name. Ordered newest first.
*
* @param string $sku Filter by product SKU (empty = all).
* @param string $lot Filter by lot number (empty = all).
* @return array
*/
public function listSkuLabels(string $sku = '', string $lot = ''): array {
$params = [':company_id' => $this->company_id];
$where = 'WHERE b.company_id = :company_id';
if ($sku !== '') {
$where .= ' AND b.product_sku = :product_sku';
$params[':product_sku'] = $sku;
}
if ($lot !== '') {
$where .= ' AND b.lot_number = :lot_number';
$params[':lot_number'] = $lot;
}
$sth = $this->pdo->prepare(
"SELECT
b.id,
b.product_sku,
b.lot_number,
b.serial_number,
b.barcode,
b.status,
b.print_count,
b.last_printed_dt,
b.created_dt,
p.product_name
FROM md_barcode b
LEFT JOIN md_product p
ON p.company_id = b.company_id
AND p.sku = b.product_sku
{$where}
AND b.barcode_type = 'sku'
ORDER BY b.created_dt DESC, b.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Create a new SKU barcode label (or re-activate a previously disabled one).
*
* Validates that the lot exists (or active stock exists for it).
* When $allowUnlisted is true, skips the lot requirement and only checks
* that the product SKU is valid — intended for pre-labelling before goods arrive.
*
* @param string $sku Product SKU.
* @param string $lot Lot number.
* @param string $serial Serial number (unique within lot/sku).
* @param bool $allowUnlisted Allow creating a label for a lot not yet in md_lot or stock.
* @return array Keys: product_sku, product_name, lot_number, serial_number, barcode.
* @throws Exception On validation failure.
*/
public function createSkuLabel(string $sku, string $lot, string $serial, bool $allowUnlisted = false): array {
$lot_row = $this->findLot($sku, $lot);
if (!$lot_row) {
if (!$allowUnlisted) throw new Exception('Lot not found.');
$product = $this->findProduct($sku);
if (!$product) throw new Exception('SKU not found.');
$lot_row = [
'product_sku' => $product['sku'],
'lot_number' => $lot,
'expiry_date' => null,
'product_name' => $product['product_name'],
'uom' => $product['uom'],
];
}
$barcode = "SKU|{$sku}|{$lot}|{$serial}";
$this->pdo->prepare(
"INSERT INTO md_barcode
(company_id, barcode_type, barcode, product_sku, lot_number, serial_number)
VALUES
(:company_id, 'sku', :barcode, :product_sku, :lot_number, :serial_number)
ON DUPLICATE KEY UPDATE
barcode_type = VALUES(barcode_type),
product_sku = VALUES(product_sku),
lot_number = VALUES(lot_number),
serial_number = VALUES(serial_number),
status = 1"
)->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':product_sku' => $sku,
':lot_number' => $lot,
':serial_number' => $serial,
]);
return [
'product_sku' => $sku,
'product_name' => $lot_row['product_name'],
'lot_number' => $lot,
'serial_number' => $serial,
'barcode' => $barcode,
];
}
// ─────────────────────────────────────────────────────────────
// LOCATION LABEL BASIS
// ─────────────────────────────────────────────────────────────
/**
* Return all location barcode labels for this company.
*
* @return array
*/
public function listLocationLabels(): array {
$sth = $this->pdo->prepare(
"SELECT barcode, warehouse_id, zone, aisle, bin, status, print_count, last_printed_dt
FROM md_barcode
WHERE company_id = :company_id
AND barcode_type = 'loc'"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Create a new location barcode label (or re-activate a previously disabled one).
*
* Validates that the bin exists in md_bin before inserting.
*
* @param string $barcode Full barcode string (e.g. "LOC|1|A|1|R1").
* @param int $warehouse_id Warehouse ID.
* @param string $zone Zone code.
* @param string $aisle Aisle code.
* @param string $bin Bin code.
* @return array Keys: barcode, warehouse_id, zone, aisle, bin.
* @throws Exception If the location is not found in md_bin.
*/
public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $bin): array {
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
$this->pdo->prepare(
"INSERT INTO md_barcode
(company_id, barcode_type, barcode, warehouse_id, zone, aisle, bin)
VALUES
(:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :bin)
ON DUPLICATE KEY UPDATE
barcode_type = VALUES(barcode_type),
warehouse_id = VALUES(warehouse_id),
zone = VALUES(zone),
aisle = VALUES(aisle),
bin = VALUES(bin),
status = 1"
)->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':warehouse_id' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':bin' => $bin,
]);
return [
'barcode' => $barcode,
'warehouse_id' => $warehouse_id,
'zone' => $zone,
'aisle' => $aisle,
'bin' => $bin,
];
}
// ─────────────────────────────────────────────────────────────
// SHARED LABEL OPERATIONS
// ─────────────────────────────────────────────────────────────
/**
* Increment print_count and update last_printed_dt for an active barcode.
*
* @param string $barcode Barcode string.
* @param string $type 'sku' or 'loc'.
*/
public function recordPrint(string $barcode, string $type): void {
$this->pdo->prepare(
"UPDATE md_barcode
SET print_count = print_count + 1,
last_printed_dt = NOW()
WHERE company_id = :company_id
AND barcode = :barcode
AND barcode_type = :barcode_type
AND status = 1"
)->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':barcode_type' => $type,
]);
}
/**
* Set the status of a barcode label (0 = disabled, 1 = active).
*
* @param string $barcode Barcode string.
* @param string $type 'sku' or 'loc'.
* @param int $status Target status value.
*/
public function setStatus(string $barcode, string $type, int $status): void {
$this->pdo->prepare(
"UPDATE md_barcode
SET status = :status
WHERE company_id = :company_id
AND barcode = :barcode
AND barcode_type = :barcode_type"
)->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':barcode_type' => $type,
':status' => $status,
]);
}
// ─────────────────────────────────────────────────────────────
// LOOKUP / RESOLUTION BASIS
// ─────────────────────────────────────────────────────────────
/**
* Resolve a barcode string to its type and fields.
*
* Dispatch order:
* 1. SKU| prefix → validated SKU label
* 2. LOC| prefix → validated location label
* 3. Fallback → raw product barcode or SKU field match in md_product
*
* Returns an array with 'type' set to 'sku', 'loc', or 'raw' plus
* the type-specific fields.
*
* The $advancedLocation flag controls how LOC barcodes are parsed;
* resolve it from the caller via CompanySettingManager::get('advanced_location').
*
* @param string $barcode Full barcode string to resolve.
* @param bool $advancedLocation True = zone/aisle/bin parts; false = bin only.
* @return array
* @throws Exception On parse or validation failure.
*/
public function lookup(string $barcode, bool $advancedLocation): array {
$parts = explode('|', $barcode);
$label_type = strtoupper($parts[0] ?? '');
if ($label_type === 'SKU') {
if (count($parts) !== 4) throw new Exception('Invalid SKU label format.');
[, $sku, $lot, $serial] = $parts;
$sku = trim($sku);
$lot = trim($lot);
$serial = trim($serial);
if ($sku === '' || $lot === '' || $serial === '') {
throw new Exception('SKU, lot, and serial are required.');
}
$label = $this->findPreparedLabel($barcode, 'sku');
if (!$label) throw new Exception('SKU barcode label was not prepared.');
if (
$label['product_sku'] !== $sku ||
$label['lot_number'] !== $lot ||
$label['serial_number'] !== $serial
) {
throw new Exception('SKU barcode label does not match its registry.');
}
$product = $this->findProduct($sku);
if (!$product) throw new Exception('SKU not found.');
$this->validateLotSerial($sku, $lot, $serial);
return [
'type' => 'sku',
'sku' => $product['sku'],
'product_name' => $product['product_name'],
'uom' => $product['uom'] ?? 'pcs',
'lot_number' => $lot,
'serial_number' => $serial,
];
}
if ($label_type === 'LOC') {
if ($advancedLocation && count($parts) !== 5) throw new Exception('Invalid advanced location label format.');
if (!$advancedLocation && count($parts) !== 3) throw new Exception('Invalid location label format.');
$warehouse_id = (int)($parts[1] ?? 0);
if ($advancedLocation) {
$zone = trim($parts[2]);
$aisle = trim($parts[3]);
$bin = trim($parts[4]);
} else {
$bin = trim($parts[2]);
$zone = $bin;
$aisle = $bin;
}
if (!$warehouse_id || $zone === '' || $aisle === '' || $bin === '') {
throw new Exception('Location label is incomplete.');
}
$label = $this->findPreparedLabel($barcode, 'loc');
if (!$label) throw new Exception('Location barcode label was not prepared.');
if (
(int)$label['warehouse_id'] !== $warehouse_id ||
$label['zone'] !== $zone ||
$label['aisle'] !== $aisle ||
$label['bin'] !== $bin
) {
throw new Exception('Location barcode label does not match its registry.');
}
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
return [
'type' => 'loc',
'warehouse_id' => $warehouse_id,
'zone' => $zone,
'aisle' => $aisle,
'bin' => $bin,
];
}
// Fallback: match against md_product's barcode or sku fields
$sth = $this->pdo->prepare(
"SELECT sku, product_name, uom
FROM md_product
WHERE company_id = :company_id
AND (barcode = :barcode OR sku = :sku)
AND status > 0
ORDER BY (barcode = :barcode_order) DESC
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':barcode' => $barcode,
':sku' => $barcode,
':barcode_order' => $barcode,
]);
$product = $sth->fetch(PDO::FETCH_ASSOC);
if (!$product) throw new Exception('Barcode not found.');
return [
'type' => 'raw',
'sku' => $product['sku'],
'product_name' => $product['product_name'],
'uom' => $product['uom'] ?? 'pcs',
];
}
// ─────────────────────────────────────────────────────────────
// DROPDOWN BASIS
// ─────────────────────────────────────────────────────────────
/**
* Return a merged list of lots for the SKU label creation UI.
*
* Combines three sources in priority order, deduplicating by SKU+lot:
* 1. md_lot rows (canonical lot master)
* 2. Active stock rows whose lot is not in md_lot
* 3. Existing barcode rows whose lot is not covered by 1 or 2
*
* Each row is augmented with barcode_active_count and barcode_disabled_count.
* Sorted by product_sku → lot_number.
*
* @return array
*/
public function getLabelLots(): array {
$sth = $this->pdo->prepare(
"SELECT
l.id,
l.product_sku,
l.lot_number,
l.expiry_date,
l.description,
p.product_name,
p.uom,
'md_lot' AS source
FROM md_lot l
INNER JOIN md_product p
ON p.company_id = l.company_id
AND p.sku = l.product_sku
WHERE l.company_id = :company_id
AND p.status > 0
ORDER BY l.product_sku ASC, l.lot_number ASC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$seen = [];
foreach ($rows as $row) {
$seen[$row['product_sku'] . '|' . $row['lot_number']] = true;
}
// Supplement from active stock tables
$warehouses = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$warehouses->execute([':company_id' => $this->company_id]);
foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) {
$table = $this->stockTableName((int)$warehouse_id);
$stock = $this->pdo->prepare(
"SELECT
s.product_sku,
s.lot_number,
p.product_name,
p.uom,
ROUND(SUM(COALESCE(s.`in`, 0)) - SUM(COALESCE(s.`out`, 0)), 2) AS balance
FROM `{$table}` s
INNER JOIN md_product p
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = :company_id
AND s.status = 1
AND s.lot_number IS NOT NULL
AND s.lot_number <> ''
AND p.status > 0
GROUP BY s.product_sku, s.lot_number, p.product_name, p.uom
HAVING balance > 0"
);
$stock->execute([':company_id' => $this->company_id]);
foreach ($stock->fetchAll(PDO::FETCH_ASSOC) as $row) {
$key = $row['product_sku'] . '|' . $row['lot_number'];
if (isset($seen[$key])) continue;
$seen[$key] = true;
$rows[] = [
'id' => null,
'product_sku' => $row['product_sku'],
'lot_number' => $row['lot_number'],
'expiry_date' => null,
'description' => 'Found in active stock; missing md_lot row.',
'product_name' => $row['product_name'],
'uom' => $row['uom'],
'source' => 'active_stock',
];
}
}
// Supplement from existing barcode rows
$barcodes = $this->pdo->prepare(
"SELECT
b.product_sku,
b.lot_number,
b.serial_number,
b.barcode,
b.status,
b.created_dt,
p.product_name,
p.uom
FROM md_barcode b
INNER JOIN md_product p
ON p.company_id = b.company_id
AND p.sku = b.product_sku
WHERE b.company_id = :company_id
AND b.barcode_type = 'sku'
AND b.product_sku IS NOT NULL AND b.product_sku <> ''
AND b.lot_number IS NOT NULL AND b.lot_number <> ''
AND p.status > 0
ORDER BY b.created_dt DESC, b.id DESC"
);
$barcodes->execute([':company_id' => $this->company_id]);
foreach ($barcodes->fetchAll(PDO::FETCH_ASSOC) as $row) {
$key = $row['product_sku'] . '|' . $row['lot_number'];
if (isset($seen[$key])) continue;
$seen[$key] = true;
$rows[] = [
'id' => null,
'product_sku' => $row['product_sku'],
'lot_number' => $row['lot_number'],
'expiry_date' => null,
'description' => 'Prepared barcode exists; missing md_lot row.',
'product_name' => $row['product_name'],
'uom' => $row['uom'],
'source' => 'md_barcode',
'serial_number' => $row['serial_number'],
'barcode' => $row['barcode'],
];
}
// Attach per-lot label counts
$status_rows = $this->pdo->prepare(
"SELECT
product_sku,
lot_number,
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS active_count,
SUM(CASE WHEN status <> 1 THEN 1 ELSE 0 END) AS disabled_count
FROM md_barcode
WHERE company_id = :company_id
AND barcode_type = 'sku'
AND product_sku IS NOT NULL AND product_sku <> ''
AND lot_number IS NOT NULL AND lot_number <> ''
GROUP BY product_sku, lot_number"
);
$status_rows->execute([':company_id' => $this->company_id]);
$label_status = [];
foreach ($status_rows->fetchAll(PDO::FETCH_ASSOC) as $row) {
$label_status[$row['product_sku'] . '|' . $row['lot_number']] = [
'active_count' => (int)$row['active_count'],
'disabled_count' => (int)$row['disabled_count'],
];
}
foreach ($rows as &$row) {
$status = $label_status[$row['product_sku'] . '|' . $row['lot_number']] ?? null;
$row['barcode_active_count'] = $status['active_count'] ?? 0;
$row['barcode_disabled_count'] = $status['disabled_count'] ?? 0;
}
unset($row);
usort($rows, fn($a, $b) => [$a['product_sku'], $a['lot_number']] <=> [$b['product_sku'], $b['lot_number']]);
return $rows;
}
/**
* Return all active products for the label creation product dropdown.
*
* @return array Each row: sku, product_name, uom.
*/
public function getLabelProducts(): array {
$sth = $this->pdo->prepare(
"SELECT sku, product_name, uom
FROM md_product
WHERE company_id = :company_id
AND status > 0
ORDER BY sku ASC"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
}