사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.

통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
taekyoungc
2026-06-01 16:15:15 +09:00
parent 21e7b91871
commit 0f1d414f37
129 changed files with 18068 additions and 1585 deletions

View File

@@ -0,0 +1,494 @@
<?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);
}
}