Files
jongryangje/app/Libraries/BagSupplyPlanBuilder.php
taekyoungc 0f1d414f37 사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.
통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 16:15:15 +09:00

495 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 쓰레기봉투 수급 계획 (레거시 w_gm820r 유추)
*
* - 바코드 봉투: bag_receiving_pack_code 에 등록된 품목
* - 기존 봉투: 그 외 (수기·비바코드 재고)
* - 소진일수 ≈ (총재고 / 월판매량) × 30
* - 발주예정일 = 기준일 + 소진일수 적정재고보유일수(제작기일)
* - 긴급(발주예정일 ≤ 기준일): 발주수량 ≈ max(0, 월판매량×18 총재고)
*/
class BagSupplyPlanBuilder
{
private const AVG_SALES_MONTHS = 12;
/** 긴급 발주 시 목표 보유 개월 수 (레거시 화면 값 유추) */
private const URGENT_REPLENISH_MONTHS = 18;
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* barcodeCodes: array<string, bool>,
* queried: bool
* }
*/
public function build(
int $lgIdx,
string $refDate,
int $leadDays,
string $stockScope,
string $salesScope,
bool $queried
): array {
$barcodeCodes = $this->loadBarcodeCodes($lgIdx);
$products = $this->loadProducts($lgIdx);
if ($products === [] || ! $queried) {
return ['rows' => [], 'barcodeCodes' => $barcodeCodes, 'queried' => $queried];
}
$inventory = $this->loadInventoryMap($lgIdx);
$pendingIn = $this->loadPendingInbound($lgIdx);
$lastOrders = $this->loadLastOrders($lgIdx);
$monthlySales = $this->loadMonthlyAverageSales($lgIdx, $refDate, $salesScope, $barcodeCodes);
$movementsSince = $this->loadMovementsSinceOrders($lgIdx, $lastOrders, $refDate);
$rows = [];
foreach ($products as $code => $name) {
$isBarcode = isset($barcodeCodes[$code]);
$rawStock = (int) ($inventory[$code] ?? 0);
$currentStock = $this->scopedStock($rawStock, $stockScope, $isBarcode);
$pendingQty = $this->scopedPending((int) ($pendingIn[$code] ?? 0), $stockScope, $isBarcode);
$totalStock = $currentStock + $pendingQty;
$monthlyFloat = (float) ($monthlySales[$code] ?? 0.0);
$monthlyAvg = (int) round($monthlyFloat);
$depletionDays = $this->calcDepletionDays($totalStock, $monthlyFloat);
$scheduleDate = $this->calcScheduleDate($refDate, $depletionDays, $leadDays);
$orderQty = $this->calcOrderQty($refDate, $scheduleDate, $depletionDays, $leadDays, $monthlyAvg, $totalStock);
$last = $lastOrders[$code] ?? null;
$orderDate = $last ? (string) ($last['order_date'] ?? '') : '';
$lastQty = $last ? (int) ($last['qty_sheet'] ?? 0) : 0;
$stockAtOrder = 0;
if ($orderDate !== '' && $lastQty > 0) {
$mv = $movementsSince[$code] ?? ['sale' => 0, 'recv' => 0, 'issue' => 0];
$stockAtOrder = max(0, $rawStock + $mv['sale'] - $mv['recv'] - $mv['issue']);
}
$rows[] = [
'bag_code' => $code,
'bag_name' => $name,
'is_barcode' => $isBarcode,
'last_order_date' => $orderDate,
'last_order_qty' => $lastQty,
'stock_at_order' => $stockAtOrder,
'current_stock' => $currentStock,
'pending_inbound' => $pendingQty,
'total_stock' => $totalStock,
'monthly_avg_sales' => $monthlyAvg,
'depletion_days' => $depletionDays,
'schedule_date' => $scheduleDate,
'schedule_overdue' => $scheduleDate !== '' && $scheduleDate <= $refDate && $depletionDays < 99999,
'order_qty' => $orderQty,
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['bag_code'], (string) $b['bag_code']));
return ['rows' => $rows, 'barcodeCodes' => $barcodeCodes, 'queried' => true];
}
/**
* @return array<string, bool>
*/
private function loadBarcodeCodes(int $lgIdx): array
{
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return [];
}
$set = [];
foreach ($this->db->table('bag_receiving_pack_code')
->select('brpc_bag_code')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code !=', '')
->get()
->getResultArray() as $row) {
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
if ($code !== '') {
$set[$code] = true;
}
}
return $set;
}
/**
* @return array<string, string> code => name
*/
private function loadProducts(int $lgIdx): array
{
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
$products = [];
if ($kindO) {
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
$code = trim((string) ($d->cd_code ?? ''));
if ($code !== '') {
$products[$code] = trim((string) ($d->cd_name ?? $code));
}
}
}
foreach ($this->db->table('bag_inventory')
->select('bi_bag_code, bi_bag_name')
->where('bi_lg_idx', $lgIdx)
->get()
->getResultArray() as $row) {
$code = trim((string) ($row['bi_bag_code'] ?? ''));
if ($code === '') {
continue;
}
if (! isset($products[$code])) {
$products[$code] = trim((string) ($row['bi_bag_name'] ?? $code));
}
}
return $products;
}
/**
* @return array<string, int>
*/
private function loadInventoryMap(int $lgIdx): array
{
$map = [];
foreach (model(\App\Models\BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll() as $inv) {
$code = trim((string) ($inv->bi_bag_code ?? ''));
if ($code !== '') {
$map[$code] = (int) ($inv->bi_qty ?? 0);
}
}
return $map;
}
/**
* @return array<string, int>
*/
private function loadPendingInbound(int $lgIdx): array
{
$map = [];
$sql = "
SELECT boi.boi_bag_code AS bag_code,
SUM(GREATEST(0, CAST(boi.boi_qty_sheet AS SIGNED) - IFNULL(r.recv_qty, 0))) AS pending_qty
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
LEFT JOIN (
SELECT br_bo_idx, br_bag_code, SUM(br_qty_sheet) AS recv_qty
FROM bag_receiving
GROUP BY br_bo_idx, br_bag_code
) r ON r.br_bo_idx = bo.bo_idx AND r.br_bag_code = boi.boi_bag_code
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
GROUP BY boi.boi_bag_code
";
foreach ($this->db->query($sql, [$lgIdx])->getResult() as $row) {
$qty = (int) ($row->pending_qty ?? 0);
if ($qty > 0) {
$map[(string) $row->bag_code] = $qty;
}
}
return $map;
}
/**
* @return array<string, array{order_date: string, qty_sheet: int, bag_name: string}>
*/
private function loadLastOrders(int $lgIdx): array
{
$map = [];
$supportsWindow = $this->db->DBDriver === 'MySQLi';
if ($supportsWindow) {
$sql = "
SELECT bag_code, order_date, qty_sheet, bag_name
FROM (
SELECT boi.boi_bag_code AS bag_code, bo.bo_order_date AS order_date,
boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name,
ROW_NUMBER() OVER (
PARTITION BY boi.boi_bag_code
ORDER BY bo.bo_order_date DESC, bo.bo_idx DESC
) AS rn
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
) t
WHERE t.rn = 1
";
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
$code = trim((string) ($row['bag_code'] ?? ''));
if ($code === '') {
continue;
}
$map[$code] = [
'order_date' => (string) ($row['order_date'] ?? ''),
'qty_sheet' => (int) ($row['qty_sheet'] ?? 0),
'bag_name' => (string) ($row['bag_name'] ?? ''),
];
}
return $map;
}
$sql = "
SELECT boi.boi_bag_code AS bag_code, MAX(bo.bo_order_date) AS order_date
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
GROUP BY boi.boi_bag_code
";
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
$code = trim((string) ($row['bag_code'] ?? ''));
$orderDate = (string) ($row['order_date'] ?? '');
if ($code === '' || $orderDate === '') {
continue;
}
$item = $this->db->query("
SELECT boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
AND boi.boi_bag_code = ? AND bo.bo_order_date = ?
ORDER BY bo.bo_idx DESC
LIMIT 1
", [$lgIdx, $code, $orderDate])->getRowArray();
if ($item) {
$map[$code] = [
'order_date' => $orderDate,
'qty_sheet' => (int) ($item['qty_sheet'] ?? 0),
'bag_name' => (string) ($item['bag_name'] ?? ''),
];
}
}
return $map;
}
/**
* @param array<string, array{order_date: string, qty_sheet: int, bag_name: string}> $lastOrders
* @return array<string, array{sale: int, recv: int, issue: int}>
*/
private function loadMovementsSinceOrders(int $lgIdx, array $lastOrders, string $refDate): array
{
$byCode = [];
$minDate = $refDate;
foreach ($lastOrders as $code => $info) {
$d = (string) ($info['order_date'] ?? '');
if ($d === '') {
continue;
}
$byCode[$code] = $d;
if ($d < $minDate) {
$minDate = $d;
}
}
if ($byCode === []) {
return [];
}
$codes = array_keys($byCode);
$placeholders = implode(',', array_fill(0, count($codes), '?'));
$params = array_merge([$lgIdx], $codes, [$minDate, $refDate]);
$out = [];
foreach ($codes as $code) {
$out[$code] = ['sale' => 0, 'recv' => 0, 'issue' => 0];
}
$sql = "
SELECT bs_bag_code AS bag_code, bs_sale_date AS mv_date,
SUM(CASE WHEN bs_type IN ('sale','cancel') THEN ABS(bs_qty) ELSE 0 END) AS sale_qty
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_bag_code IN ({$placeholders})
AND bs_sale_date >= ? AND bs_sale_date <= ?
GROUP BY bs_bag_code, bs_sale_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['sale'] += (int) $row->sale_qty;
}
$sql = "
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS recv_qty
FROM bag_receiving
WHERE br_lg_idx = ? AND br_bag_code IN ({$placeholders})
AND br_receive_date >= ? AND br_receive_date <= ?
GROUP BY br_bag_code, br_receive_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['recv'] += (int) $row->recv_qty;
}
$sql = "
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, SUM(bi2_qty) AS issue_qty
FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$placeholders})
AND bi2_issue_date >= ? AND bi2_issue_date <= ?
GROUP BY bi2_bag_code, bi2_issue_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['issue'] += (int) $row->issue_qty;
}
return $out;
}
/**
* @param array<string, bool> $barcodeCodes
* @return array<string, float>
*/
private function loadMonthlyAverageSales(
int $lgIdx,
string $refDate,
string $salesScope,
array $barcodeCodes
): array {
$fromDate = date('Y-m-d', strtotime($refDate . ' -' . self::AVG_SALES_MONTHS . ' months'));
$legacyNet = [];
$barcodeNet = [];
foreach ($this->db->query("
SELECT bs_bag_code AS bag_code,
SUM(CASE WHEN bs_type = 'sale' THEN ABS(bs_qty)
WHEN bs_type IN ('return','cancel') THEN -ABS(bs_qty)
ELSE 0 END) AS net_qty
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date > ? AND bs_sale_date <= ?
GROUP BY bs_bag_code
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
$code = (string) $row->bag_code;
$legacyNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
}
if ($this->db->tableExists('bag_sale_scan_code')) {
foreach ($this->db->query("
SELECT bssc_bag_code AS bag_code, SUM(bssc_qty) AS net_qty
FROM bag_sale_scan_code
WHERE bssc_lg_idx = ? AND bssc_state = 'sold'
AND DATE(bssc_regdate) > ? AND DATE(bssc_regdate) <= ?
GROUP BY bssc_bag_code
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
$code = (string) $row->bag_code;
$barcodeNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
}
}
$merged = [];
$allCodes = array_unique(array_merge(array_keys($legacyNet), array_keys($barcodeNet)));
foreach ($allCodes as $code) {
$isBarcode = isset($barcodeCodes[$code]);
$legacy = $legacyNet[$code] ?? 0.0;
$scan = $barcodeNet[$code] ?? 0.0;
$merged[$code] = match ($salesScope) {
'legacy' => $isBarcode ? 0.0 : $legacy,
'barcode' => $isBarcode ? ($scan > 0 ? $scan : $legacy) : 0.0,
default => $isBarcode && $scan > 0 ? $scan : $legacy,
};
}
return $merged;
}
private function scopedStock(int $qty, string $scope, bool $isBarcode): int
{
return match ($scope) {
'legacy' => $isBarcode ? 0 : $qty,
'barcode' => $isBarcode ? $qty : 0,
default => $qty,
};
}
private function scopedPending(int $qty, string $scope, bool $isBarcode): int
{
return $this->scopedStock($qty, $scope, $isBarcode);
}
private function calcDepletionDays(int $totalStock, float $monthlyAvg): int
{
if ($monthlyAvg <= 0.0) {
return 0;
}
return (int) round(($totalStock / $monthlyAvg) * 30);
}
private function calcScheduleDate(string $refDate, int $depletionDays, int $leadDays): string
{
if ($depletionDays <= 0) {
return '';
}
if ($depletionDays >= 99999) {
return '';
}
$offset = $depletionDays - $leadDays;
if ($offset < 0) {
return $refDate;
}
$base = \DateTimeImmutable::createFromFormat('Y-m-d', $refDate);
if ($base === false) {
return '';
}
try {
$scheduled = $base->modify('+' . $offset . ' days');
} catch (\Exception) {
return '';
}
$year = (int) $scheduled->format('Y');
$refYear = (int) $base->format('Y');
if ($year < $refYear - 1 || $year > $refYear + 120) {
return '';
}
return $scheduled->format('Y-m-d');
}
private function calcOrderQty(
string $refDate,
string $scheduleDate,
int $depletionDays,
int $leadDays,
int $monthlyAvg,
int $totalStock
): int {
if ($monthlyAvg <= 0) {
return 0;
}
$urgent = $scheduleDate !== '' && $scheduleDate <= $refDate;
$lowStock = $depletionDays > 0 && $depletionDays <= $leadDays && $scheduleDate !== '';
if (! $urgent && ! $lowStock) {
return 0;
}
$target = (int) round($monthlyAvg * ($urgent ? self::URGENT_REPLENISH_MONTHS : max(2, (int) ceil($leadDays / 30.0))));
return max(0, $target - $totalStock);
}
}