pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function stockTableName(int $warehouse_id): string { 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 = $this->stockTableName((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 rack row exists for the given warehouse + zone + aisle + rack. * Throws if not found. */ private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $rack): void { $sth = $this->pdo->prepare( "SELECT 1 FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND COALESCE(zone, '') = :zone AND COALESCE(aisle,'') = :aisle AND rack = :rack LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle, ':rack' => $rack, ]); 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 = $this->stockTableName((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, rack, 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 rack exists in md_rack 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 $rack Rack code. * @return array Keys: barcode, warehouse_id, zone, aisle, rack. * @throws Exception If the location is not found in md_rack. */ public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $rack): array { $this->validateLocation($warehouse_id, $zone, $aisle, $rack); $this->pdo->prepare( "INSERT INTO md_barcode (company_id, barcode_type, barcode, warehouse_id, zone, aisle, rack) VALUES (:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :rack) ON DUPLICATE KEY UPDATE barcode_type = VALUES(barcode_type), warehouse_id = VALUES(warehouse_id), zone = VALUES(zone), aisle = VALUES(aisle), rack = VALUES(rack), status = 1" )->execute([ ':company_id' => $this->company_id, ':barcode' => $barcode, ':warehouse_id' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle, ':rack' => $rack, ]); return [ 'barcode' => $barcode, 'warehouse_id' => $warehouse_id, 'zone' => $zone, 'aisle' => $aisle, 'rack' => $rack, ]; } // ───────────────────────────────────────────────────────────── // 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/rack parts; false = rack 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]); $rack = trim($parts[4]); } else { $rack = trim($parts[2]); $zone = $rack; $aisle = $rack; } if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') { 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['rack'] !== $rack ) { throw new Exception('Location barcode label does not match its registry.'); } $this->validateLocation($warehouse_id, $zone, $aisle, $rack); return [ 'type' => 'loc', 'warehouse_id' => $warehouse_id, 'zone' => $zone, 'aisle' => $aisle, 'rack' => $rack, ]; } // 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); } }