사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 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,463 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 기간별 봉투 수불 현황 집계 (bag/flow)
*/
class BagFlowReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
private static function bagCodeKey(mixed $code): string
{
return (string) $code;
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* bagKindLabels: array<string, string>,
* queried: bool
* }
*/
public function build(
int $lgIdx,
string $startDate,
string $endDate,
string $aggMode,
string $bagCodeFilter,
string $bagKindFilter,
int $saIdx,
bool $queried
): array {
$bagKindLabels = $this->loadBagKindLabels();
$products = $this->loadProducts($lgIdx, $bagCodeFilter, $bagKindFilter);
if ($products === [] || ! $queried) {
return ['rows' => [], 'bagKindLabels' => $bagKindLabels, 'queried' => $queried];
}
$codes = array_keys($products);
$dayBefore = date('Y-m-d', strtotime($startDate . ' -1 day'));
$openingRaw = $this->aggregateMovements($lgIdx, $codes, $saIdx, null, $dayBefore);
$opening = $this->collapseOpeningBalances($openingRaw);
$periodMoves = $this->aggregateMovements($lgIdx, $codes, $saIdx, $startDate, $endDate);
if ($aggMode === 'daily') {
$rows = $this->buildDailyRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
} else {
$rows = $this->buildPeriodRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
}
return ['rows' => $rows, 'bagKindLabels' => $bagKindLabels, 'queried' => true];
}
/**
* @return array<string, string> code => name
*/
private function loadProducts(int $lgIdx, string $bagCodeFilter, string $bagKindFilter): array
{
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
if (! $kindO) {
return [];
}
$details = model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx);
$products = [];
foreach ($details as $d) {
$code = (string) ($d->cd_code ?? '');
if ($code === '') {
continue;
}
if ($bagCodeFilter !== '' && $code !== $bagCodeFilter) {
continue;
}
if ($bagKindFilter !== '' && ! str_starts_with($code, $bagKindFilter)) {
continue;
}
$products[self::bagCodeKey($code)] = (string) ($d->cd_name ?? $code);
}
return $products;
}
/**
* @return array<string, string>
*/
private function loadBagKindLabels(): array
{
$kindE = model(\App\Models\CodeKindModel::class)->where('ck_code', 'E')->first();
if (! $kindE) {
return [];
}
$labels = [];
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null) as $d) {
$labels[(string) $d->cd_code] = (string) $d->cd_name;
}
return $labels;
}
/**
* @param list<string> $codes
* @return array<string, array<string, array<string, int>>> bag_code => date => metrics
*/
private function aggregateMovements(
int $lgIdx,
array $codes,
int $saIdx,
?string $fromDate,
?string $toDate
): array {
if ($codes === []) {
return [];
}
$buckets = [];
$ensure = static function (string $code, string $date) use (&$buckets): array {
if (! isset($buckets[$code][$date])) {
$buckets[$code][$date] = self::emptyMetrics();
}
return $buckets[$code][$date];
};
$hasMisc = $this->db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$codePlaceholders = implode(',', array_fill(0, count($codes), '?'));
// 입고(발주 입고)
$sql = "
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS qty
FROM bag_receiving
WHERE br_lg_idx = ? AND br_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND br_receive_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND br_receive_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY br_bag_code, br_receive_date';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$m = $ensure($code, $date);
$m['recv_in'] += (int) $row->qty;
$buckets[$code][$date] = $m;
}
// 판매·반품(반품=입고)
$sql = "
SELECT bs.bs_bag_code AS bag_code, bs.bs_sale_date AS mv_date, bs.bs_type AS mv_type,
SUM(ABS(bs.bs_qty)) AS qty
FROM bag_sale bs
";
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_sa_idx = ?';
}
$sql .= " WHERE bs.bs_lg_idx = ? AND bs.bs_bag_code IN ({$codePlaceholders})";
$params = $saIdx > 0 && $hasDsSa ? [$saIdx, $lgIdx] : [$lgIdx];
$params = array_merge($params, $codes);
if ($fromDate !== null) {
$sql .= ' AND bs.bs_sale_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bs.bs_sale_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bs.bs_bag_code, bs.bs_sale_date, bs.bs_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
$type = (string) $row->mv_type;
if ($type === 'return') {
$m['recv_return'] += $qty;
} else {
$m['out_sale'] += $qty;
}
$buckets[$code][$date] = $m;
}
// 불출
$sql = "
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, bi2_issue_type AS issue_type,
SUM(bi2_qty) AS qty
FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND bi2_issue_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bi2_issue_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bi2_bag_code, bi2_issue_date, bi2_issue_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
$issueType = (string) $row->issue_type;
if (str_contains($issueType, '무료')) {
$m['out_issue_free'] += $qty;
} else {
$m['out_issue_gen'] += $qty;
}
$buckets[$code][$date] = $m;
}
if ($hasMisc) {
$sql = "
SELECT bmf_bag_code AS bag_code, bmf_date AS mv_date, bmf_type AS mv_type,
SUM(bmf_qty) AS qty
FROM bag_misc_flow
WHERE bmf_lg_idx = ? AND bmf_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND bmf_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bmf_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bmf_bag_code, bmf_date, bmf_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
if ((string) $row->mv_type === 'in') {
$m['recv_misc'] += $qty;
} else {
$m['out_misc'] += $qty;
}
$buckets[$code][$date] = $m;
}
}
$normalized = [];
foreach ($buckets as $code => $byDate) {
$key = self::bagCodeKey($code);
foreach ($byDate as $date => $m) {
$normalized[$key][$date] = self::finalizeMetrics($m);
}
}
return $normalized;
}
/**
* @param array<string, string> $products
* @param array<string, array<string, int>> $opening date key '_open'
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
private function buildPeriodRows(
array $products,
array $opening,
array $periodMoves,
string $startDate,
string $endDate,
array $bagKindLabels
): array {
$periodKey = $startDate . '~' . $endDate;
$grouped = [];
foreach ($products as $codeKey => $name) {
$code = self::bagCodeKey($codeKey);
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
$grouped[$kind][] = ['code' => $code, 'name' => $name];
}
ksort($grouped);
$rows = [];
$grand = self::emptyMetrics();
$grand['row_type'] = 'grand';
$grand['date'] = '';
$grand['item_name'] = '총계';
foreach ($grouped as $kind => $items) {
$sub = self::emptyMetrics();
$sub['row_type'] = 'subtotal';
$sub['date'] = '';
$sub['item_name'] = ($bagKindLabels[$kind] ?? '기타') . ' 소계';
foreach ($items as $item) {
$code = self::bagCodeKey($item['code']);
$m = self::emptyMetrics();
foreach ($periodMoves[$code] ?? [] as $dayMetrics) {
$m = self::mergeMetrics($m, $dayMetrics);
}
$m = self::finalizeMetrics($m);
$m['prev_stock'] = (int) ($opening[$code] ?? 0);
$m['balance'] = $m['prev_stock'] + $m['recv_total'] - $m['out_total'];
$m['row_type'] = 'data';
$m['date'] = $periodKey;
$m['item_name'] = $item['name'];
$m['bag_code'] = $code;
$m['bag_kind'] = $kind;
$rows[] = $m;
$sub = self::mergeMetrics($sub, $m);
}
$sub = self::finalizeMetrics($sub);
$sub['balance'] = $sub['prev_stock'] + $sub['recv_total'] - $sub['out_total'];
$rows[] = $sub;
$grand = self::mergeMetrics($grand, $sub);
}
$grand = self::finalizeMetrics($grand);
$grand['balance'] = $grand['prev_stock'] + $grand['recv_total'] - $grand['out_total'];
$rows[] = $grand;
return $rows;
}
/**
* @param array<string, array<string, array<string, int>>> $openingRaw
* @return array<string, int> bag_code => 전일(기간 전) 재고
*/
private function collapseOpeningBalances(array $openingRaw): array
{
$out = [];
foreach ($openingRaw as $code => $byDate) {
$net = self::emptyMetrics();
foreach ($byDate as $m) {
$net = self::mergeMetrics($net, $m);
}
$net = self::finalizeMetrics($net);
$out[self::bagCodeKey($code)] = $net['recv_total'] - $net['out_total'];
}
return $out;
}
/**
* @param array<string, string> $products
* @param array<string, array<string, array<string, int>>> $opening
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
private function buildDailyRows(
array $products,
array $opening,
array $periodMoves,
string $startDate,
string $endDate,
array $bagKindLabels
): array {
$dates = [];
$cursor = strtotime($startDate);
$endTs = strtotime($endDate);
while ($cursor <= $endTs) {
$dates[] = date('Y-m-d', $cursor);
$cursor = strtotime('+1 day', $cursor);
}
$rows = [];
foreach ($products as $codeKey => $name) {
$code = self::bagCodeKey($codeKey);
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
$running = (int) ($opening[$code] ?? 0);
foreach ($dates as $date) {
$dayM = $periodMoves[$code][$date] ?? self::emptyMetrics();
$dayM = self::finalizeMetrics($dayM);
$prev = $running;
$running = $prev + $dayM['recv_total'] - $dayM['out_total'];
$dayM['prev_stock'] = $prev;
$dayM['balance'] = $running;
$dayM['row_type'] = 'data';
$dayM['date'] = $date;
$dayM['item_name'] = $name;
$dayM['bag_code'] = $code;
$dayM['bag_kind'] = $kind;
if ($this->rowHasActivity($dayM)) {
$rows[] = $dayM;
}
}
}
return $rows;
}
/**
* @param array<string, int|float> $m
*/
private function rowHasActivity(array $m): bool
{
foreach (['recv_in', 'recv_return', 'recv_misc', 'out_sale', 'out_issue_gen', 'out_issue_free', 'out_return', 'out_misc'] as $k) {
if ((int) ($m[$k] ?? 0) !== 0) {
return true;
}
}
return (int) ($m['prev_stock'] ?? 0) !== 0;
}
/**
* @return array<string, int>
*/
private static function emptyMetrics(): array
{
return [
'prev_stock' => 0,
'recv_in' => 0,
'recv_return' => 0,
'recv_misc' => 0,
'recv_total' => 0,
'out_sale' => 0,
'out_issue_gen' => 0,
'out_issue_free' => 0,
'out_return' => 0,
'out_misc' => 0,
'out_total' => 0,
'balance' => 0,
];
}
/**
* @param array<string, int> $m
* @return array<string, int>
*/
private static function finalizeMetrics(array $m): array
{
$m['recv_total'] = (int) $m['recv_in'] + (int) $m['recv_return'] + (int) $m['recv_misc'];
$m['out_total'] = (int) $m['out_sale'] + (int) $m['out_issue_gen'] + (int) $m['out_issue_free']
+ (int) $m['out_return'] + (int) $m['out_misc'];
return $m;
}
/**
* @param array<string, int> $a
* @param array<string, int> $b
* @return array<string, int>
*/
private static function mergeMetrics(array $a, array $b): array
{
foreach (self::emptyMetrics() as $k => $_) {
$a[$k] = (int) ($a[$k] ?? 0) + (int) ($b[$k] ?? 0);
}
return $a;
}
}