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

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

660 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 통계 분석 관리 (전년대비 / 월별·계절별 추이)
*/
class BagAnalyticsReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
/** @var array<string, string> */
private array $bagNames = [];
/**
* @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}>
*/
public static function seasonCatalog(): array
{
return [
'spring' => ['label' => '봄', 'months_label' => '3~5월', 'months' => [3, 4, 5], 'cross_year' => false],
'summer' => ['label' => '여름', 'months_label' => '6~8월', 'months' => [6, 7, 8], 'cross_year' => false],
'autumn' => ['label' => '가을', 'months_label' => '9~11월', 'months' => [9, 10, 11], 'cross_year' => false],
'winter' => ['label' => '겨울', 'months_label' => '전년12·1~2월', 'months' => [12, 1, 2], 'cross_year' => true],
];
}
public static function normalizeSeason(string $season): string
{
$raw = trim($season);
$aliases = [
'봄' => 'spring',
'여름' => 'summer',
'가을' => 'autumn',
'겨울' => 'winter',
];
$key = $aliases[$raw] ?? strtolower($raw);
$catalog = self::seasonCatalog();
return isset($catalog[$key]) ? $key : 'spring';
}
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* gugunOptions: list<array{code: string, name: string}>,
* agencies: list<object>,
* lgName: string,
* gugunLabel: string
* }
*/
public function loadFilterOptions(int $lgIdx): array
{
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : '';
$gugunRows = $this->db->query("
SELECT DISTINCT ds_gugun_code AS code
FROM designated_shop
WHERE ds_lg_idx = ? AND ds_gugun_code != ''
ORDER BY ds_gugun_code
", [$lgIdx])->getResultArray();
$gugunOptions = [['code' => '', 'name' => '전체']];
foreach ($gugunRows as $row) {
$code = trim((string) ($row['code'] ?? ''));
if ($code === '') {
continue;
}
$gugunOptions[] = ['code' => $code, 'name' => $this->gugunLabel($lgIdx, $code)];
}
$agencies = model(\App\Models\SalesAgencyModel::class)
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
return [
'gugunOptions' => $gugunOptions,
'agencies' => $agencies,
'lgName' => $lgName,
'gugunLabel' => '',
];
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* months: list<int>,
* prevYear: int,
* year: int
* }
*/
public function buildYearOverYear(
int $lgIdx,
int $year,
string $gugunCode,
int $dsIdx,
bool $queried
): array {
$prevYear = $year - 1;
$months = range(1, 12);
if (! $queried) {
return ['rows' => [], 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
}
$this->loadBagNames($lgIdx);
$agg = $this->aggregateMonthlyByBag($lgIdx, $prevYear, $year, $gugunCode, $dsIdx);
$rows = [];
$codesFromAgg = array_map(static fn ($c): string => (string) $c, array_keys($agg));
foreach ($this->bagCodesForReport($lgIdx, $codesFromAgg) as $code) {
$code = (string) $code;
$name = $this->resolveBagName($code);
$rows[] = $this->yoyBlock($code, (string) $name, '수량', $agg, $prevYear, $year, $months, false);
$rows[] = $this->yoyBlock($code, (string) $name, '금액', $agg, $prevYear, $year, $months, true);
}
return ['rows' => $rows, 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
}
/**
* @return array{rows: list<array<string, mixed>>, meta: array<string, int>}
*/
public function buildMonthlyTrend(
int $lgIdx,
string $baseYm,
string $trendBasis,
float $deviationMin,
string $gugunCode,
int $saIdx,
bool $queried
): array {
$empty = ['rows' => [], 'meta' => ['shopCount' => 0, 'monthSalesShops' => 0]];
if (! $queried || ! preg_match('/^(\d{4})-(\d{2})$/', $baseYm, $m)) {
return $empty;
}
$year = (int) $m[1];
$month = (int) $m[2];
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
if ($shops === []) {
return $empty;
}
$monthlyByShop = $this->monthlyNetByShop($lgIdx, $year, $month, $gugunCode, $saIdx);
$avgByShop = $this->averageNetByShop($lgIdx, $year - 1, $gugunCode, $trendBasis, $month, $saIdx);
$prevYearSameMonth = $trendBasis === 'year_avg'
? $this->monthlyNetByShop($lgIdx, $year - 1, $month, $gugunCode, $saIdx)
: [];
$rows = [];
$monthSalesShops = 0;
foreach ($shops as $shop) {
$sid = (int) ($shop['ds_idx'] ?? 0);
$monthly = (float) ($monthlyByShop[$sid] ?? 0.0);
$avg = (float) ($avgByShop[$sid] ?? 0.0);
if ($trendBasis === 'year_avg' && $avg <= 0) {
$avg = (float) ($prevYearSameMonth[$sid] ?? 0.0);
}
if ($monthly > 0) {
$monthSalesShops++;
}
$diff = $monthly - $avg;
$pct = $avg > 0 ? round(($diff / $avg) * 100, 2) : ($monthly > 0 ? 100.0 : 0.0);
// 편차 N% 이상 = |편차(%)| ≥ N (증가·감소 모두)
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
continue;
}
$rows[] = [
'agency_name' => (string) ($shop['agency_name'] ?? ''),
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
'shop_name' => (string) ($shop['ds_name'] ?? ''),
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
'prev_avg' => (int) round($avg),
'monthly_qty' => (int) round($monthly),
'avg_diff' => (int) round($diff),
'deviation_pct'=> $pct,
'designated_at'=> (string) ($shop['ds_designated_at'] ?? ''),
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
return [
'rows' => $rows,
'meta' => [
'shopCount' => count($shops),
'monthSalesShops' => $monthSalesShops,
],
];
}
/**
* @return list<array<string, mixed>>
*/
public function buildSeasonalTrend(
int $lgIdx,
int $baseYear,
string $season,
float $deviationMin,
string $gugunCode,
bool $queried
): array {
if (! $queried) {
return [];
}
$seasonKey = self::normalizeSeason($season);
$seasonDef = self::seasonCatalog()[$seasonKey];
$months = $seasonDef['months'];
$saIdx = 0;
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
if ($shops === []) {
return [];
}
$crossYear = (bool) ($seasonDef['cross_year'] ?? false);
$currentByShop = $this->seasonalNetByShop($lgIdx, $baseYear, $months, $gugunCode, $saIdx, $crossYear);
$prevByShop = $this->seasonalNetByShop($lgIdx, $baseYear - 1, $months, $gugunCode, $saIdx, $crossYear);
$rows = [];
foreach ($shops as $shop) {
$sid = (int) ($shop['ds_idx'] ?? 0);
$curr = (float) ($currentByShop[$sid] ?? 0.0);
$prev = (float) ($prevByShop[$sid] ?? 0.0);
$diff = $curr - $prev;
$pct = $prev > 0 ? round(($diff / $prev) * 100, 2) : ($curr > 0 ? 100.0 : 0.0);
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
continue;
}
$rows[] = [
'agency_name' => (string) ($shop['agency_name'] ?? ''),
'shop_name' => (string) ($shop['ds_name'] ?? ''),
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
'prev_season_avg'=> (int) round($prev),
'base_season_avg'=> (int) round($curr),
'avg_diff' => (int) round($diff),
'deviation_pct' => $pct,
'designated_at' => (string) ($shop['ds_designated_at'] ?? ''),
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
return $rows;
}
private function gugunLabel(int $lgIdx, string $code): string
{
static $cache = [];
$key = $lgIdx . ':' . $code;
if (isset($cache[$key])) {
return $cache[$key];
}
$row = $this->db->table('code_detail cd')
->select('cd.cd_name')
->join('code_kind ck', 'ck.ck_idx = cd.cd_ck_idx', 'inner')
->where('ck.ck_code', 'G')
->where('cd.cd_lg_idx', $lgIdx)
->where('cd.cd_code', $code)
->get()
->getRowArray();
$cache[$key] = trim((string) ($row['cd_name'] ?? $code));
return $cache[$key];
}
private function resolveBagName(string $code): string
{
if (isset($this->bagNames[$code])) {
return (string) $this->bagNames[$code];
}
if (ctype_digit($code) && isset($this->bagNames[(int) $code])) {
return (string) $this->bagNames[(int) $code];
}
return $code;
}
private function loadBagNames(int $lgIdx): void
{
if ($this->bagNames !== []) {
return;
}
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
if (! $kindO) {
return;
}
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
$code = trim((string) ($d->cd_code ?? ''));
if ($code !== '') {
$this->bagNames[$code] = trim((string) ($d->cd_name ?? $code));
}
}
}
/**
* @param list<string> $codesFromAgg
* @return list<string>
*/
private function bagCodesForReport(int $lgIdx, array $codesFromAgg): array
{
$this->loadBagNames($lgIdx);
$codes = array_keys($this->bagNames);
if ($codesFromAgg !== []) {
$merged = array_merge($codes, $codesFromAgg);
$codes = [];
foreach ($merged as $c) {
$codes[] = (string) $c;
}
$codes = array_values(array_unique($codes));
sort($codes, SORT_STRING);
} else {
$codes = array_map(static fn ($c): string => (string) $c, $codes);
}
return $codes;
}
/**
* @return array<string, array<int, array<int, array{qty: float, amt: float}>>>
*/
private function aggregateMonthlyByBag(
int $lgIdx,
int $fromYear,
int $toYear,
string $gugunCode,
int $dsIdx
): array {
$sql = "
SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END) AS net_qty,
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_amount)
ELSE 0 END) AS net_amt
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ?
AND YEAR(bs.bs_sale_date) BETWEEN ? AND ?
";
$params = [$lgIdx, $fromYear, $toYear];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($dsIdx > 0) {
$sql .= ' AND bs.bs_ds_idx = ?';
$params[] = $dsIdx;
}
$sql .= ' GROUP BY bs.bs_bag_code, YEAR(bs.bs_sale_date), MONTH(bs.bs_sale_date)';
$agg = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$code = (string) ($row['bag_code'] ?? '');
$y = (int) ($row['y'] ?? 0);
$m = (int) ($row['m'] ?? 0);
if ($code === '' || $y <= 0 || $m <= 0) {
continue;
}
$agg[$code][$y][$m] = [
'qty' => (float) ($row['net_qty'] ?? 0),
'amt' => (float) ($row['net_amt'] ?? 0),
];
}
return $agg;
}
/**
* @param array<string, array<int, array<int, array{qty: float, amt: float}>>> $agg
* @param list<int> $months
* @return array<string, mixed>
*/
private function yoyBlock(
string $code,
string $name,
string $section,
array $agg,
int $prevYear,
int $year,
array $months,
bool $useAmount
): array {
$key = $useAmount ? 'amt' : 'qty';
$lines = [];
foreach ([$prevYear => (string) $prevYear . '년', $year => (string) $year . '년', 0 => '증감'] as $y => $label) {
$monthVals = [];
$total = 0.0;
foreach ($months as $mo) {
$v = 0.0;
if ($y === 0) {
$p = (float) ($agg[$code][$prevYear][$mo][$key] ?? 0);
$c = (float) ($agg[$code][$year][$mo][$key] ?? 0);
$v = $c - $p;
} else {
$v = (float) ($agg[$code][$y][$mo][$key] ?? 0);
}
$monthVals[$mo] = (int) round($v);
$total += $v;
}
$lines[] = ['label' => $label, 'months' => $monthVals, 'total' => (int) round($total)];
}
return [
'bag_code' => $code,
'bag_name' => $name,
'section' => $section,
'lines' => $lines,
];
}
/**
* @return list<array<string, mixed>>
*/
/**
* @return array<string, string> 구·군코드 → 대행소명
*/
private function agencyNameByGugun(int $lgIdx): array
{
$best = [];
foreach ($this->db->query("
SELECT TRIM(bo.bo_gugun_code) AS code, bo.bo_agency_idx AS sa_idx, COUNT(*) AS cnt
FROM bag_order bo
WHERE bo.bo_lg_idx = ?
AND bo.bo_status = 'normal'
AND bo.bo_agency_idx IS NOT NULL
AND TRIM(bo.bo_gugun_code) != ''
GROUP BY TRIM(bo.bo_gugun_code), bo.bo_agency_idx
", [$lgIdx])->getResultArray() as $row) {
$code = (string) ($row['code'] ?? '');
$cnt = (int) ($row['cnt'] ?? 0);
if ($code === '') {
continue;
}
if (! isset($best[$code]) || $cnt > $best[$code]['cnt']) {
$best[$code] = ['cnt' => $cnt, 'sa_idx' => (int) ($row['sa_idx'] ?? 0)];
}
}
$names = [];
foreach ($best as $code => $info) {
$sa = model(\App\Models\SalesAgencyModel::class)->find($info['sa_idx']);
$names[$code] = $sa ? trim((string) ($sa->sa_name ?? '')) : '';
}
return $names;
}
private function loadShops(int $lgIdx, string $gugunCode, int $saIdx = 0): array
{
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$agencyByGugun = $this->agencyNameByGugun($lgIdx);
$sql = '
SELECT ds.ds_idx, ds.ds_shop_no, ds.ds_name, ds.ds_rep_name, ds.ds_designated_at,
ds.ds_gugun_code';
if ($hasDsSa) {
$sql .= ', ds.ds_sa_idx';
}
$sql .= '
FROM designated_shop ds
WHERE ds.ds_lg_idx = ? AND ds.ds_state = 1
';
$params = [$lgIdx];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' ORDER BY ds.ds_shop_no ASC, ds.ds_idx ASC';
$rows = $this->db->query($sql, $params)->getResultArray();
$saNames = [];
if ($hasDsSa) {
foreach (model(\App\Models\SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $sa) {
$saNames[(int) ($sa->sa_idx ?? 0)] = trim((string) ($sa->sa_name ?? ''));
}
}
foreach ($rows as &$row) {
$name = '';
if ($hasDsSa) {
$saidx = (int) ($row['ds_sa_idx'] ?? 0);
$name = $saNames[$saidx] ?? '';
}
if ($name === '') {
$code = trim((string) ($row['ds_gugun_code'] ?? ''));
$name = $agencyByGugun[$code] ?? '';
}
$row['agency_name'] = $name;
}
unset($row);
return $rows;
}
/**
* @return array<int, float>
*/
private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array
{
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END) AS net_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ?
AND bs.bs_ds_idx IS NOT NULL
";
$params = [$lgIdx, $year, $month];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['net_qty'] ?? 0);
}
return $map;
}
/**
* @return array<int, float>
*/
private function averageNetByShop(
int $lgIdx,
int $year,
string $gugunCode,
string $trendBasis,
int $refMonth,
int $saIdx = 0
): array {
if ($trendBasis === 'month') {
return $this->monthlyNetByShop($lgIdx, $year, $refMonth, $gugunCode, $saIdx);
}
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END) / 12 AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
AND bs.bs_ds_idx IS NOT NULL
";
$params = [$lgIdx, $year];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
}
return $map;
}
/**
* @param list<int> $months
* @return array<int, float>
*/
private function seasonalNetByShop(
int $lgIdx,
int $year,
array $months,
string $gugunCode,
int $saIdx = 0,
bool $crossYearWinter = false
): array {
if ($months === []) {
return [];
}
$divisor = count($months);
$qtyExpr = "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END";
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
if ($crossYearWinter) {
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$qtyExpr}) / ? AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ?
AND bs.bs_ds_idx IS NOT NULL
AND (
(YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = 12)
OR (YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) IN (1, 2))
)
";
$params = [$divisor, $lgIdx, $year - 1, $year];
} else {
$placeholders = implode(',', array_fill(0, count($months), '?'));
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$qtyExpr}) / ? AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
AND MONTH(bs.bs_sale_date) IN ({$placeholders})
AND bs.bs_ds_idx IS NOT NULL
";
$params = array_merge([$divisor], [$lgIdx, $year], $months);
}
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
}
return $map;
}
}