통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
495 lines
18 KiB
PHP
495 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|