*/ private array $bagNames = []; /** * @return array, 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, * agencies: list, * 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>, * months: list, * 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>, meta: array} */ 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> */ 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 $codesFromAgg * @return list */ 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>> */ 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>> $agg * @param list $months * @return array */ 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> */ /** * @return array 구·군코드 → 대행소명 */ 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 */ 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 */ 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 $months * @return array */ 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; } }