to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); $modeRaw = (string) ($this->request->getGet('mode') ?? 'daily'); $mode = $modeRaw === 'period' ? 'period' : 'daily'; $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0); $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); $export = (int) ($this->request->getGet('export') ?? 0) === 1; $catGet = $this->request->getGet('cat'); $cats = is_array($catGet) ? array_values(array_filter(array_map('strval', $catGet))) : []; if ($cats === [] || in_array('all', $cats, true)) { $cats = []; } $db = \Config\Database::connect(); $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; $shops = model(DesignatedShopModel::class) ->where('ds_lg_idx', $lgIdx) ->orderBy('ds_name', 'ASC') ->orderBy('ds_shop_no', 'ASC') ->findAll(); $agencies = model(SalesAgencyModel::class) ->where('sa_lg_idx', $lgIdx) ->orderForDisplay() ->findAll(); $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; $detailRows = $this->fetchSalesLedgerDetailRows( $db, $lgIdx, $startDate, $endDate, $dsIdx, $effectiveSaIdx, $hasBsFee ); $filtered = []; foreach ($detailRows as $row) { if ($this->ledgerRowMatchesCategories($row, $cats)) { $filtered[] = $row; } } if ($mode === 'daily') { $built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee); } else { $built = $this->buildPeriodLedgerPresentation($filtered, $hasBsFee); } if ($export) { $headers = $mode === 'daily' ? ['일자', '지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분'] : ['지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분']; $exportRows = []; foreach ($built['rows'] as $r) { $kind = (string) ($r['kind'] ?? 'data'); $label = $kind === 'subtotal' ? '소계' : ($kind === 'grand' ? '합계' : ''); if ($mode === 'daily') { $exportRows[] = [ (string) ($r['sale_date'] ?? ''), (string) ($r['designation_no'] ?? ''), (string) ($r['shop_name'] ?? ''), (string) ($r['rep_name'] ?? ''), (string) ($r['address'] ?? ''), (string) ($r['product_name'] ?? ''), (string) ($r['qty'] ?? ''), (string) ($r['amount'] ?? ''), (string) ($r['fee'] ?? ''), (string) ($r['total'] ?? ''), $label, ]; } else { $exportRows[] = [ (string) ($r['designation_no'] ?? ''), (string) ($r['shop_name'] ?? ''), (string) ($r['rep_name'] ?? ''), (string) ($r['address'] ?? ''), (string) ($r['product_name'] ?? ''), (string) ($r['qty'] ?? ''), (string) ($r['amount'] ?? ''), (string) ($r['fee'] ?? ''), (string) ($r['total'] ?? ''), $label, ]; } } $sheetName = $mode === 'daily' ? '일자별 판매대장' : '기간별 판매대장'; export_excel_2003_xml( '지정판매소_판매대장_' . $startDate . '_' . $endDate, $sheetName, $headers, $exportRows ); } $shopLabel = '전체'; if ($dsIdx > 0) { foreach ($shops as $s) { if ((int) ($s->ds_idx ?? 0) === $dsIdx) { $shopLabel = (string) ($s->ds_name ?? ''); break; } } } $agencyModel = model(SalesAgencyModel::class); $hasKindCode = $agencyModel->hasKindCodeColumns(); $agencyLabel = '전체'; if ($saIdx > 0) { foreach ($agencies as $a) { if ((int) ($a->sa_idx ?? 0) === $saIdx) { $agencyLabel = $hasKindCode ? trim('[' . (string) ($a->sa_kind ?? '') . '] ' . (string) ($a->sa_code ?? '') . ' ' . (string) ($a->sa_name ?? '')) : trim((string) ($a->sa_name ?? '')); break; } } } $printSubtitleLines = [ trim($lgName . ' / 지정판매소: ' . $shopLabel . ' / 대행소: ' . $agencyLabel), '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · (단위: 매 / 원)', ]; return $this->renderWorkPage('지정 판매소 판매 대장', 'admin/sales_report/sales_ledger', [ 'ledgerRows' => $built['rows'], 'saleLineCount' => $built['saleLineCount'], 'startDate' => $startDate, 'endDate' => $endDate, 'mode' => $mode, 'dsIdx' => $dsIdx, 'saIdx' => $saIdx, 'cats' => $cats, 'shops' => $shops, 'agencies' => $agencies, 'lgName' => $lgName, 'filterShopLabel' => $shopLabel, 'filterAgencyLabel' => $agencyLabel, 'printSubtitleLines' => $printSubtitleLines, ]); } /** * @return list */ private function fetchSalesLedgerDetailRows( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $startDate, string $endDate, int $dsIdx, int $saIdx, bool $hasBsFee ): array { $feeExpr = $hasBsFee ? 'COALESCE(bs.bs_fee, 0)' : '0'; $select = 'bs.bs_sale_date, bs.bs_ds_idx, bs.bs_bag_code, bs.bs_bag_name, ' . 'ABS(bs.bs_qty) AS line_qty, bs.bs_amount AS line_amount, ' . $feeExpr . ' AS line_fee, ' . 'COALESCE(ds.ds_shop_no, \'\') AS ds_shop_no, COALESCE(ds.ds_name, bs.bs_ds_name) AS ds_name, ' . 'COALESCE(ds.ds_rep_name, \'\') AS ds_rep_name, ' . "TRIM(CONCAT(COALESCE(ds.ds_addr,''), ' ', COALESCE(ds.ds_addr_detail,''))) AS ds_addr, " . 'COALESCE(ds.ds_zone_code, \'\') AS ds_zone_code, COALESCE(ds.ds_gugun_code, \'\') AS ds_gugun_code'; $builder = $db->table('bag_sale bs'); $builder->select($select, false); $builder->join('designated_shop ds', 'ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx', 'left'); $builder->where('bs.bs_lg_idx', $lgIdx); $builder->where('bs.bs_sale_date >=', $startDate); $builder->where('bs.bs_sale_date <=', $endDate); $builder->where('bs.bs_type', 'sale'); if ($dsIdx > 0) { $builder->where('bs.bs_ds_idx', $dsIdx); } if ($saIdx > 0) { $builder->where('ds.ds_sa_idx', $saIdx); } $builder->orderBy('bs.bs_sale_date', 'ASC'); $builder->orderBy('ds.ds_shop_no', 'ASC'); $builder->orderBy('bs.bs_ds_idx', 'ASC'); $builder->orderBy('bs.bs_bag_code', 'ASC'); return $builder->get()->getResult(); } /** * @param list $cats 빈 배열이면 전체 */ private function ledgerRowMatchesCategories(object $row, array $cats): bool { if ($cats === []) { return true; } $name = (string) ($row->bs_bag_name ?? ''); $code = (string) ($row->bs_bag_code ?? ''); $cat = $this->classifyLedgerProductCategory($name, $code); return in_array($cat, $cats, true); } private function classifyLedgerProductCategory(string $bagName, string $bagCode): string { $s = $bagName . ' ' . $bagCode; if (mb_strpos($s, '스티커') !== false) { return 'sticker'; } if (mb_strpos($s, '용기') !== false) { return 'container'; } if (mb_strpos($s, '공동주택') !== false) { return 'apt'; } if (mb_strpos($s, '공공') !== false) { return 'public_use'; } if (mb_strpos($s, '재사용') !== false) { return 'reuse'; } if (mb_strpos($s, '음식물') !== false) { return 'food'; } if (mb_strpos($s, '폐기물') !== false) { return 'waste'; } return 'general'; } /** * @param list $rows * @return array{rows: list>, saleLineCount: int} */ private function buildDailyLedgerPresentation(array $rows, bool $hasBsFee): array { $out = []; $saleLineCount = count($rows); $grandQty = $grandAmt = $grandFee = $grandTot = 0; $prevKey = null; $subQty = $subAmt = $subFee = $subTot = 0; $flushSubtotal = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void { if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) { return; } $out[] = [ 'kind' => 'subtotal', 'sale_date' => '', 'designation_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'product_name' => '소 계', 'qty' => number_format($subQty), 'amount' => number_format((int) round($subAmt)), 'fee' => number_format((int) round($subFee)), 'total' => number_format((int) round($subTot)), ]; $subQty = $subAmt = $subFee = $subTot = 0; }; foreach ($rows as $row) { $date = (string) $row->bs_sale_date; $dsId = (int) ($row->bs_ds_idx ?? 0); $key = $date . '|' . $dsId; if ($prevKey !== null && $key !== $prevKey) { $flushSubtotal(); } $prevKey = $key; $qty = (int) round((float) ($row->line_qty ?? 0)); $amt = (float) ($row->line_amount ?? 0); $fee = $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0; $total = $amt + $fee; $designation = $this->formatDesignationNo( (string) ($row->ds_zone_code ?? ''), (string) ($row->ds_gugun_code ?? ''), (string) ($row->ds_shop_no ?? '') ); $out[] = [ 'kind' => 'data', 'sale_date' => $date, 'designation_no' => $designation, 'shop_name' => (string) ($row->ds_name ?? ''), 'rep_name' => (string) ($row->ds_rep_name ?? ''), 'address' => trim((string) ($row->ds_addr ?? '')), 'product_name' => (string) ($row->bs_bag_name ?? ''), 'qty' => number_format($qty), 'amount' => number_format((int) round($amt)), 'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '', 'total' => number_format((int) round($total)), ]; $subQty += $qty; $subAmt += $amt; $subFee += $fee; $subTot += $total; $grandQty += $qty; $grandAmt += $amt; $grandFee += $fee; $grandTot += $total; } $flushSubtotal(); $out[] = [ 'kind' => 'grand', 'sale_date' => '', 'designation_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'product_name' => '합 계', 'qty' => number_format($grandQty), 'amount' => number_format((int) round($grandAmt)), 'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '', 'total' => number_format((int) round($grandTot)), ]; return ['rows' => $out, 'saleLineCount' => $saleLineCount]; } /** * @param list $rows * @return array{rows: list>, saleLineCount: int} */ private function buildPeriodLedgerPresentation(array $rows, bool $hasBsFee): array { $groups = []; foreach ($rows as $row) { $dsId = (int) ($row->bs_ds_idx ?? 0); $code = (string) ($row->bs_bag_code ?? ''); $name = (string) ($row->bs_bag_name ?? ''); $gk = $dsId . '|' . $code . '|' . $name; if (! isset($groups[$gk])) { $groups[$gk] = [ 'bs_ds_idx' => $dsId, 'ds_shop_no' => (string) ($row->ds_shop_no ?? ''), 'ds_name' => (string) ($row->ds_name ?? ''), 'ds_rep_name' => (string) ($row->ds_rep_name ?? ''), 'ds_addr' => trim((string) ($row->ds_addr ?? '')), 'ds_zone_code' => (string) ($row->ds_zone_code ?? ''), 'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''), 'bs_bag_code' => $code, 'bs_bag_name' => $name, 'qty' => 0, 'amount' => 0.0, 'fee' => 0.0, ]; } $groups[$gk]['qty'] += (int) round((float) ($row->line_qty ?? 0)); $groups[$gk]['amount'] += (float) ($row->line_amount ?? 0); $groups[$gk]['fee'] += $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0; } $saleLineCount = count($rows); uasort($groups, static function (array $a, array $b): int { $cmp = strcmp((string) ($a['ds_shop_no'] ?? ''), (string) ($b['ds_shop_no'] ?? '')); if ($cmp !== 0) { return $cmp; } $cmp2 = strcmp((string) ($a['bs_bag_code'] ?? ''), (string) ($b['bs_bag_code'] ?? '')); return $cmp2 !== 0 ? $cmp2 : strcmp((string) ($a['bs_bag_name'] ?? ''), (string) ($b['bs_bag_name'] ?? '')); }); $out = []; $prevDs = null; $subQty = $subAmt = $subFee = $subTot = 0; $grandQty = $grandAmt = $grandFee = $grandTot = 0; $flushSub = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void { if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) { return; } $out[] = [ 'kind' => 'subtotal', 'designation_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'product_name' => '소 계', 'qty' => number_format($subQty), 'amount' => number_format((int) round($subAmt)), 'fee' => number_format((int) round($subFee)), 'total' => number_format((int) round($subTot)), ]; $subQty = $subAmt = $subFee = $subTot = 0; }; foreach ($groups as $g) { $dsId = (int) ($g['bs_ds_idx'] ?? 0); if ($prevDs !== null && $dsId !== $prevDs) { $flushSub(); } $prevDs = $dsId; $qty = (int) ($g['qty'] ?? 0); $amt = (float) ($g['amount'] ?? 0); $fee = (float) ($g['fee'] ?? 0); $total = $amt + $fee; $designation = $this->formatDesignationNo( (string) ($g['ds_zone_code'] ?? ''), (string) ($g['ds_gugun_code'] ?? ''), (string) ($g['ds_shop_no'] ?? '') ); $out[] = [ 'kind' => 'data', 'designation_no' => $designation, 'shop_name' => (string) ($g['ds_name'] ?? ''), 'rep_name' => (string) ($g['ds_rep_name'] ?? ''), 'address' => (string) ($g['ds_addr'] ?? ''), 'product_name' => (string) ($g['bs_bag_name'] ?? ''), 'qty' => number_format($qty), 'amount' => number_format((int) round($amt)), 'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '', 'total' => number_format((int) round($total)), ]; $subQty += $qty; $subAmt += $amt; $subFee += $fee; $subTot += $total; $grandQty += $qty; $grandAmt += $amt; $grandFee += $fee; $grandTot += $total; } $flushSub(); $out[] = [ 'kind' => 'grand', 'designation_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'product_name' => '합 계', 'qty' => number_format($grandQty), 'amount' => number_format((int) round($grandAmt)), 'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '', 'total' => number_format((int) round($grandTot)), ]; return ['rows' => $out, 'saleLineCount' => $saleLineCount]; } private function formatDesignationNo(string $zoneCode, string $gugunCode, string $shopNo): string { $head = trim($zoneCode) !== '' ? trim($zoneCode) : trim($gugunCode); $shopNo = trim($shopNo); if ($head !== '' && $shopNo !== '') { return $head . ' - ' . $shopNo; } if ($shopNo !== '') { return $shopNo; } return $head; } /** * P5-02: 일계표 (일계 + 당월 누계, 대행소·구분, 엑셀·인쇄) */ public function dailySummary() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $date = (string) ($this->request->getGet('date') ?? date('Y-m-d')); $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); $export = (int) ($this->request->getGet('export') ?? 0) === 1; $monthStart = date('Y-m-01', strtotime($date)); $db = \Config\Database::connect(); $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; $agencies = model(SalesAgencyModel::class) ->where('sa_lg_idx', $lgIdx) ->orderForDisplay() ->findAll(); $aggRows = $this->fetchDailySummaryBagAggregates( $db, $lgIdx, $date, $monthStart, $hasBsFee, $effectiveSaIdx ); $catOrder = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste']; $catLabels = [ 'general' => '일반용', 'food' => '음식물', 'sticker' => '스티커', 'reuse' => '재사용', 'apt' => '공동주택용', 'public_use' => '공공용', 'container' => '용기', 'waste' => '폐기물', ]; $byCat = []; foreach ($catOrder as $ck) { $byCat[$ck] = []; } foreach ($aggRows as $row) { $name = (string) ($row->bs_bag_name ?? ''); $code = (string) ($row->bs_bag_code ?? ''); $ck = $this->classifyLedgerProductCategory($name, $code); if ($catFilter !== '' && $ck !== $catFilter) { continue; } $dQty = (int) round((float) ($row->d_qty ?? 0)); $dAmt = (float) ($row->d_amt ?? 0); $dFee = $hasBsFee ? (float) ($row->d_fee ?? 0) : 0.0; $mQty = (int) round((float) ($row->m_qty ?? 0)); $mAmt = (float) ($row->m_amt ?? 0); $mFee = $hasBsFee ? (float) ($row->m_fee ?? 0) : 0.0; $byCat[$ck][] = [ 'bag_code' => $code, 'bag_name' => $name !== '' ? $name : $code, 'd_qty' => $dQty, 'd_amt' => $dAmt, 'd_fee' => $dFee, 'd_levy' => $dAmt + $dFee, 'm_qty' => $mQty, 'm_amt' => $mAmt, 'm_fee' => $mFee, 'm_levy' => $mAmt + $mFee, ]; } $tableRows = []; $gD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; $gM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; foreach ($catOrder as $ck) { $items = $byCat[$ck] ?? []; if ($items === []) { continue; } usort($items, static fn (array $a, array $b): int => strcmp((string) ($a['bag_code'] ?? ''), (string) ($b['bag_code'] ?? ''))); $sD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; $sM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; foreach ($items as $it) { $tableRows[] = array_merge(['kind' => 'data', 'cat_key' => $ck, 'cat_label' => $catLabels[$ck] ?? $ck], $it); $sD['qty'] += $it['d_qty']; $sD['amt'] += $it['d_amt']; $sD['fee'] += $it['d_fee']; $sD['levy'] += $it['d_levy']; $sM['qty'] += $it['m_qty']; $sM['amt'] += $it['m_amt']; $sM['fee'] += $it['m_fee']; $sM['levy'] += $it['m_levy']; } $tableRows[] = [ 'kind' => 'subtotal', 'cat_key' => $ck, 'cat_label' => $catLabels[$ck] ?? $ck, 'bag_name' => '소 계', 'bag_code' => '', 'd_qty' => $sD['qty'], 'd_amt' => $sD['amt'], 'd_fee' => $sD['fee'], 'd_levy' => $sD['levy'], 'm_qty' => $sM['qty'], 'm_amt' => $sM['amt'], 'm_fee' => $sM['fee'], 'm_levy' => $sM['levy'], ]; $gD['qty'] += $sD['qty']; $gD['amt'] += $sD['amt']; $gD['fee'] += $sD['fee']; $gD['levy'] += $sD['levy']; $gM['qty'] += $sM['qty']; $gM['amt'] += $sM['amt']; $gM['fee'] += $sM['fee']; $gM['levy'] += $sM['levy']; } if ($tableRows !== []) { $tableRows[] = [ 'kind' => 'grand', 'cat_key' => '', 'cat_label' => '', 'bag_name' => '합 계', 'bag_code' => '', 'd_qty' => $gD['qty'], 'd_amt' => $gD['amt'], 'd_fee' => $gD['fee'], 'd_levy' => $gD['levy'], 'm_qty' => $gM['qty'], 'm_amt' => $gM['amt'], 'm_fee' => $gM['fee'], 'm_levy' => $gM['levy'], ]; } $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; $agencyLabel = '전체'; if ($saIdx > 0) { foreach ($agencies as $a) { if ((int) ($a->sa_idx ?? 0) === $saIdx) { $agencyLabel = trim((string) ($a->sa_name ?? '')); break; } } } $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; if ($export) { $headers = [ '구분', '봉투종류', '일계_수량', '일계_판매금액', '일계_수수료', '일계_징수액', '누계(월)_수량', '누계(월)_판매금액', '누계(월)_수수료', '누계(월)_징수액', ]; $exportRows = []; foreach ($tableRows as $r) { $exportRows[] = [ (string) ($r['cat_label'] ?? ''), (string) ($r['bag_name'] ?? ''), (string) ($r['d_qty'] ?? ''), (string) (int) round((float) ($r['d_amt'] ?? 0)), (string) (int) round((float) ($r['d_fee'] ?? 0)), (string) (int) round((float) ($r['d_levy'] ?? 0)), (string) ($r['m_qty'] ?? ''), (string) (int) round((float) ($r['m_amt'] ?? 0)), (string) (int) round((float) ($r['m_fee'] ?? 0)), (string) (int) round((float) ($r['m_levy'] ?? 0)), ]; } export_excel_2003_xml( '일계표_' . $date, '일계표', $headers, $exportRows ); } $printExtraLines = [ '조회일: ' . $date . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter, '누계 구간: ' . $monthStart . ' ~ ' . $date . ' · (단위: 매 / 원)', ]; return $this->renderWorkPage('일계표', 'admin/sales_report/daily_summary', [ 'tableRows' => $tableRows, 'date' => $date, 'monthStart' => $monthStart, 'saIdx' => $saIdx, 'catFilter' => $catFilter, 'agencies' => $agencies, 'catLabels' => $catLabels, 'hasBsFee' => $hasBsFee, 'lgName' => $lgName, 'agencyLabel' => $agencyLabel, 'catLabelFilter' => $catLabelFilter, 'printExtraLines' => $printExtraLines, ]); } /** * 품목(봉투코드·명)별 당일·당월 누계 집계 (판매만) * * @return list */ private function fetchDailySummaryBagAggregates( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $date, string $monthStart, bool $hasBsFee, int $saIdx ): array { $feeDaily = $hasBsFee ? 'SUM(CASE WHEN bs.bs_sale_date = ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)' : 'CAST(0 AS DECIMAL(14,2))'; $feeMonth = $hasBsFee ? 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)' : 'CAST(0 AS DECIMAL(14,2))'; $sql = 'SELECT bs.bs_bag_code, bs.bs_bag_name, ' . 'SUM(CASE WHEN bs.bs_sale_date = ? THEN ABS(bs.bs_qty) ELSE 0 END) AS d_qty, ' . 'SUM(CASE WHEN bs.bs_sale_date = ? THEN bs.bs_amount ELSE 0 END) AS d_amt, ' . $feeDaily . ' AS d_fee, ' . 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN ABS(bs.bs_qty) ELSE 0 END) AS m_qty, ' . 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN bs.bs_amount ELSE 0 END) AS m_amt, ' . $feeMonth . ' AS m_fee ' . 'FROM bag_sale bs ' . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' . 'WHERE bs.bs_lg_idx = ? AND bs.bs_type = ? AND bs.bs_sale_date BETWEEN ? AND ? '; $params = [ $date, $date, ]; if ($hasBsFee) { $params[] = $date; } $params[] = $monthStart; $params[] = $date; $params[] = $monthStart; $params[] = $date; if ($hasBsFee) { $params[] = $monthStart; $params[] = $date; } $params[] = $lgIdx; $params[] = 'sale'; $params[] = $monthStart; $params[] = $date; if ($saIdx > 0) { $sql .= ' AND ds.ds_sa_idx = ? '; $params[] = $saIdx; } $sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name ORDER BY bs.bs_bag_code'; return $db->query($sql, $params)->getResult(); } private function isFoodOrStickerLedgerCategory(string $catKey): bool { return $catKey === 'food' || $catKey === 'sticker'; } /** * 기간별 판매현황 집계 (일자+품목 또는 품목만) * * @return list */ private function fetchPeriodSalesAggregates( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $startDate, string $endDate, bool $hasBsFee, int $saIdx, bool $byDaily ): array { $feeExpr = $hasBsFee ? "SUM(CASE WHEN bs.bs_type = 'sale' THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)" : 'CAST(0 AS DECIMAL(14,2))'; $dateSelect = $byDaily ? 'bs.bs_sale_date AS bs_sale_date, ' : ''; $groupBy = $byDaily ? 'bs.bs_sale_date, bs.bs_bag_code, bs.bs_bag_name' : 'bs.bs_bag_code, bs.bs_bag_name'; $orderBy = $byDaily ? 'bs.bs_sale_date ASC, bs.bs_bag_code ASC' : 'bs.bs_bag_code ASC'; $sql = 'SELECT ' . $dateSelect . 'bs.bs_bag_code, bs.bs_bag_name, ' . "SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END) AS s_qty, " . "SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS s_amt, " . $feeExpr . ' AS s_fee, ' . "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_qty) ELSE 0 END) AS r_qty, " . "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_amount) ELSE 0 END) AS r_amt " . 'FROM bag_sale bs ' . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' . "WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type IN ('sale','return') "; $params = [$lgIdx, $startDate, $endDate]; if ($saIdx > 0) { $sql .= ' AND ds.ds_sa_idx = ? '; $params[] = $saIdx; } $sql .= 'GROUP BY ' . $groupBy . ' ORDER BY ' . $orderBy; return $db->query($sql, $params)->getResult(); } /** * @param list $rawRows * * @return array{lines: list>, foot_all: array, foot_bag: array, foot_fs: array} */ private function buildPeriodSalesPresentation(array $rawRows, bool $byDaily, string $catFilter, bool $hasBsFee): array { $empty = static fn (): array => [ 's_qty' => 0, 's_amt' => 0.0, 's_fee' => 0.0, 's_levy' => 0.0, 'r_qty' => 0, 'r_amt' => 0.0, 't_qty' => 0, 't_amt' => 0.0, 't_fee' => 0.0, 't_levy' => 0.0, ]; $dataFlat = []; foreach ($rawRows as $row) { $name = (string) ($row->bs_bag_name ?? ''); $code = (string) ($row->bs_bag_code ?? ''); $ck = $this->classifyLedgerProductCategory($name, $code); if ($catFilter !== '' && $ck !== $catFilter) { continue; } $sQty = (int) round((float) ($row->s_qty ?? 0)); $sAmt = (float) ($row->s_amt ?? 0); $sFee = $hasBsFee ? (float) ($row->s_fee ?? 0) : 0.0; $sLev = $sAmt + $sFee; $rQty = (int) round((float) ($row->r_qty ?? 0)); $rAmt = (float) ($row->r_amt ?? 0); $tQty = $sQty - $rQty; $tAmt = $sAmt - $rAmt; $tFee = $sFee; $tLev = $tAmt + $tFee; $ymd = $byDaily ? (string) ($row->bs_sale_date ?? '') : ''; $dataFlat[] = [ 'code' => $code, 'name' => $name !== '' ? $name : $code, 'ck' => $ck, 'ymd' => $ymd, 's_qty' => $sQty, 's_amt' => $sAmt, 's_fee' => $sFee, 's_levy' => $sLev, 'r_qty' => $rQty, 'r_amt' => $rAmt, 't_qty' => $tQty, 't_amt' => $tAmt, 't_fee' => $tFee, 't_levy' => $tLev, ]; } $addBucket = static function (array $b, array $row): array { $b['s_qty'] += $row['s_qty']; $b['s_amt'] += $row['s_amt']; $b['s_fee'] += $row['s_fee']; $b['s_levy'] += $row['s_levy']; $b['r_qty'] += $row['r_qty']; $b['r_amt'] += $row['r_amt']; $b['t_qty'] += $row['t_qty']; $b['t_amt'] += $row['t_amt']; $b['t_fee'] += $row['t_fee']; $b['t_levy'] += $row['t_levy']; return $b; }; $footAll = $empty(); $footBag = $empty(); $footFs = $empty(); foreach ($dataFlat as $row) { $footAll = $addBucket($footAll, $row); if ($this->isFoodOrStickerLedgerCategory((string) $row['ck'])) { $footFs = $addBucket($footFs, $row); } else { $footBag = $addBucket($footBag, $row); } } if ($dataFlat === []) { return [ 'lines' => [], 'foot_all' => $footAll, 'foot_bag' => $footBag, 'foot_fs' => $footFs, ]; } $lines = []; if ($byDaily) { $byDate = []; foreach ($dataFlat as $row) { $d = (string) $row['ymd']; if ($d === '') { continue; } $byDate[$d][] = $row; } ksort($byDate); foreach ($byDate as $ymd => $items) { if ($items === []) { continue; } usort($items, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code'])); $blockLen = count($items) + 3; $i = 0; foreach ($items as $it) { $lines[] = array_merge($it, [ 'kind' => 'data', 'ymd_rowspan' => $i === 0 ? $blockLen : 0, ]); ++$i; } $dayAll = $empty(); $dayBag = $empty(); $dayFs = $empty(); foreach ($items as $it) { $dayAll = $addBucket($dayAll, $it); if ($this->isFoodOrStickerLedgerCategory((string) $it['ck'])) { $dayFs = $addBucket($dayFs, $it); } else { $dayBag = $addBucket($dayBag, $it); } } foreach ( [ ['day_sub_all', '소 계', $dayAll], ['day_sub_bag', '봉투계', $dayBag], ['day_sub_fs', '음식물 / 스티커 계', $dayFs], ] as $trip ) { $lines[] = [ 'kind' => $trip[0], 'ymd' => '', 'ymd_rowspan' => 0, 'code' => '', 'name' => (string) $trip[1], 'ck' => '', 's_qty' => $trip[2]['s_qty'], 's_amt' => $trip[2]['s_amt'], 's_fee' => $trip[2]['s_fee'], 's_levy' => $trip[2]['s_levy'], 'r_qty' => $trip[2]['r_qty'], 'r_amt' => $trip[2]['r_amt'], 't_qty' => $trip[2]['t_qty'], 't_amt' => $trip[2]['t_amt'], 't_fee' => $trip[2]['t_fee'], 't_levy' => $trip[2]['t_levy'], ]; } } } else { usort($dataFlat, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code'])); foreach ($dataFlat as $it) { $lines[] = array_merge($it, [ 'kind' => 'data', 'ymd_rowspan' => 0, ]); } } foreach ( [ ['foot_all', '합 계', $footAll], ['foot_bag', '봉투계', $footBag], ['foot_fs', '음식물 / 스티커 계', $footFs], ] as $ft ) { $lines[] = [ 'kind' => $ft[0], 'ymd' => '', 'ymd_rowspan' => 0, 'code' => '', 'name' => (string) $ft[1], 'ck' => '', 's_qty' => $ft[2]['s_qty'], 's_amt' => $ft[2]['s_amt'], 's_fee' => $ft[2]['s_fee'], 's_levy' => $ft[2]['s_levy'], 'r_qty' => $ft[2]['r_qty'], 'r_amt' => $ft[2]['r_amt'], 't_qty' => $ft[2]['t_qty'], 't_amt' => $ft[2]['t_amt'], 't_fee' => $ft[2]['t_fee'], 't_levy' => $ft[2]['t_levy'], ]; } return [ 'lines' => $lines, 'foot_all' => $footAll, 'foot_bag' => $footBag, 'foot_fs' => $footFs, ]; } /** * P5-03: 기간별 판매현황 (일자별·기간별 집계, 대행소·구분, 판매/반품/계, 엑셀·인쇄) */ public function periodSales() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); $mode = trim((string) ($this->request->getGet('mode') ?? 'daily')); $byDaily = ($mode !== 'period'); $export = (int) ($this->request->getGet('export') ?? 0) === 1; $db = \Config\Database::connect(); $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; $agencies = model(SalesAgencyModel::class) ->where('sa_lg_idx', $lgIdx) ->orderForDisplay() ->findAll(); $raw = $this->fetchPeriodSalesAggregates($db, $lgIdx, $startDate, $endDate, $hasBsFee, $effectiveSaIdx, $byDaily); $pres = $this->buildPeriodSalesPresentation($raw, $byDaily, $catFilter, $hasBsFee); $lines = $pres['lines']; $catLabels = [ 'general' => '일반용', 'food' => '음식물', 'sticker' => '스티커', 'reuse' => '재사용', 'apt' => '공동주택용', 'public_use' => '공공용', 'container' => '용기', 'waste' => '폐기물', ]; $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; $agencyLabel = '전체'; if ($saIdx > 0) { foreach ($agencies as $a) { if ((int) ($a->sa_idx ?? 0) === $saIdx) { $agencyLabel = trim((string) ($a->sa_name ?? '')); break; } } } $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; $modeLabel = $byDaily ? '일자별' : '기간별'; if ($export) { $headers = [ '일자', '품목', '판매_수량', '판매_판매금액', '판매_수수료', '판매_징수액', '반품_수량', '반품_금액', '계_수량', '계_판매금액', '계_수수료', '계_징수액', ]; $exportRows = []; foreach ($lines as $ln) { $kind = (string) ($ln['kind'] ?? ''); $ymd = (string) ($ln['ymd'] ?? ''); if (! $byDaily && $kind === 'data') { $ymd = ''; } $exportRows[] = [ $ymd, (string) ($ln['name'] ?? ''), (string) ($ln['s_qty'] ?? '0'), (string) (int) round((float) ($ln['s_amt'] ?? 0)), $hasBsFee ? (string) (int) round((float) ($ln['s_fee'] ?? 0)) : '', (string) (int) round((float) ($ln['s_levy'] ?? 0)), (string) ($ln['r_qty'] ?? '0'), (string) (int) round((float) ($ln['r_amt'] ?? 0)), (string) ($ln['t_qty'] ?? '0'), (string) (int) round((float) ($ln['t_amt'] ?? 0)), $hasBsFee ? (string) (int) round((float) ($ln['t_fee'] ?? 0)) : '', (string) (int) round((float) ($ln['t_levy'] ?? 0)), ]; } export_excel_2003_xml( '기간별판매현황_' . $startDate . '_' . $endDate, '기간별판매현황', $headers, $exportRows ); } $printExtraLines = [ '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $modeLabel . ' · (단위: 매 / 원)', ]; return $this->renderWorkPage('기간별 판매현황', 'admin/sales_report/period_sales', [ 'lines' => $lines, 'startDate' => $startDate, 'endDate' => $endDate, 'saIdx' => $saIdx, 'catFilter' => $catFilter, 'mode' => $byDaily ? 'daily' : 'period', 'agencies' => $agencies, 'catLabels' => $catLabels, 'hasBsFee' => $hasBsFee, 'lgName' => $lgName, 'agencyLabel' => $agencyLabel, 'catLabelFilter' => $catLabelFilter, 'printExtraLines' => $printExtraLines, ]); } /** * P5-04: 년 판매 현황 (품목별 합계·월·분기, 구·군·대행소, 수수료·징수액, 엑셀·인쇄) */ public function yearlySales() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $year = (int) ($this->request->getGet('year') ?? date('Y')); $year = max(2000, min(2100, $year)); $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? '')); $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); $export = (int) ($this->request->getGet('export') ?? 0) === 1; $db = \Config\Database::connect(); $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; $monthRows = $this->fetchYearlySalesMonthlyAggregates( $db, $lgIdx, $year, $gugunCode, $effectiveSaIdx, $hasBsFee ); $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); $gugunOptions = $this->yearlySalesGugunDropdownRows($lgIdx); $built = $this->buildYearlySalesPresentation($monthRows, $hasBsFee); $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; $gugunLabel = '전체'; if ($gugunCode !== '') { foreach ($gugunOptions as $g) { if (($g['code'] ?? '') === $gugunCode) { $gugunLabel = trim((string) ($g['name'] ?? $gugunCode)); break; } } } $agencyLabel = '전체'; if ($saIdx > 0) { foreach ($agencies as $a) { if ((int) ($a->sa_idx ?? 0) === $saIdx) { $agencyLabel = trim((string) ($a->sa_name ?? '')); break; } } } if ($export) { $headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec'])); $exportRows = []; foreach ($built['itemBlocks'] as $block) { foreach ($block['lines'] as $li) { $exportRows[] = array_merge( [(string) ($block['name'] ?? ''), (string) ($li['measure'] ?? '')], array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? []) ); } } if ($built['itemBlocks'] !== []) { foreach ($built['footerBlock']['lines'] as $li) { $exportRows[] = array_merge( [(string) ($built['footerBlock']['name'] ?? ''), (string) ($li['measure'] ?? '')], array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? []) ); } } $exportStamp = date('Ymd_His'); export_excel_2003_xml( '년판매현황_' . $year . '_' . $exportStamp, $year . '년판매', $headers, $exportRows ); } $printExtraLines = [ '구·군: ' . $gugunLabel . ' · 대행소: ' . $agencyLabel, '(단위: 매 / 원)', ]; return $this->renderWorkPage($year . '년 판매 현황', 'admin/sales_report/yearly_sales', [ 'year' => $year, 'gugunCode' => $gugunCode, 'saIdx' => $saIdx, 'agencies' => $agencies, 'gugunOptions' => $gugunOptions, 'colSpec' => $built['colSpec'], 'itemBlocks' => $built['itemBlocks'], 'footerBlock' => $built['footerBlock'], 'hasBsFee' => $hasBsFee, 'lgName' => $lgName, 'gugunLabel' => $gugunLabel, 'agencyLabel' => $agencyLabel, 'printExtraLines' => $printExtraLines, 'hasYearlyData' => $built['itemBlocks'] !== [], ]); } /** * @return list */ private function yearlySalesColumnSpec(): array { return [ ['id' => 'total', 'label' => '합계'], ['id' => 'm1', 'label' => '1월'], ['id' => 'm2', 'label' => '2월'], ['id' => 'm3', 'label' => '3월'], ['id' => 'q1', 'label' => '1분기'], ['id' => 'm4', 'label' => '4월'], ['id' => 'm5', 'label' => '5월'], ['id' => 'm6', 'label' => '6월'], ['id' => 'q2', 'label' => '2분기'], ['id' => 'm7', 'label' => '7월'], ['id' => 'm8', 'label' => '8월'], ['id' => 'm9', 'label' => '9월'], ['id' => 'q3', 'label' => '3분기'], ['id' => 'm10', 'label' => '10월'], ['id' => 'm11', 'label' => '11월'], ['id' => 'm12', 'label' => '12월'], ['id' => 'q4', 'label' => '4분기'], ]; } private function codeKindIdxByCkCodeForSalesReport(string $ckCode): ?int { $k = model(CodeKindModel::class) ->where('ck_code', $ckCode) ->where('ck_state', 1) ->first(); return $k !== null ? (int) $k->ck_idx : null; } /** * @return list */ private function yearlySalesGugunDropdownRows(int $lgIdx): array { $map = []; $ckIdx = $this->codeKindIdxByCkCodeForSalesReport('C'); if ($ckIdx !== null) { foreach (model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx) as $r) { $map[trim((string) ($r->cd_code ?? ''))] = trim((string) ($r->cd_name ?? '')); } } $db = \Config\Database::connect(); $codes = $db->query( "SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c", [$lgIdx] )->getResult(); $out = []; foreach ($codes as $o) { $c = trim((string) ($o->c ?? '')); if ($c === '') { continue; } $name = $map[$c] ?? $c; $out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c]; } foreach ($map as $c => $name) { if ($c === '' || isset($out[$c])) { continue; } $out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c]; } $rows = array_values($out); usort($rows, static fn (array $a, array $b): int => strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''))); return $rows; } /** * 품목·월별 판매 집계 (판매만) * * @return list */ private function fetchYearlySalesMonthlyAggregates( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, int $year, string $gugunCode, int $saIdx, bool $hasBsFee ): array { $feeExpr = $hasBsFee ? 'SUM(COALESCE(bs.bs_fee, 0))' : 'CAST(0 AS DECIMAL(14,2))'; $sql = 'SELECT bs.bs_bag_code AS c, bs.bs_bag_name AS n, MONTH(bs.bs_sale_date) AS mo, ' . 'SUM(ABS(bs.bs_qty)) AS qty, SUM(bs.bs_amount) AS amt, ' . $feeExpr . ' AS fee ' . 'FROM bag_sale bs ' . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' . 'WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND bs.bs_type = ? '; $params = [$lgIdx, $year, 'sale']; if ($gugunCode !== '') { $sql .= ' AND ds.ds_gugun_code = ? '; $params[] = $gugunCode; } if ($saIdx > 0) { $sql .= ' AND ds.ds_sa_idx = ? '; $params[] = $saIdx; } $sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name, MONTH(bs.bs_sale_date) ' . 'ORDER BY bs.bs_bag_code, MONTH(bs.bs_sale_date)'; return $db->query($sql, $params)->getResult(); } /** * @param list $monthRows * * @return array{ * colSpec: list, * itemBlocks: list, exportCells: list}>>}, * footerBlock: array{name: string, lines: list, exportCells: list}>} * } */ private function buildYearlySalesPresentation(array $monthRows, bool $hasBsFee): array { $colSpec = $this->yearlySalesColumnSpec(); /** @var array}> $products */ $products = []; foreach ($monthRows as $row) { $code = trim((string) ($row->c ?? '')); $name = trim((string) ($row->n ?? '')); $mo = (int) ($row->mo ?? 0); if ($code === '' || $mo < 1 || $mo > 12) { continue; } $pkey = $code . "\x1f" . ($name !== '' ? $name : $code); if (! isset($products[$pkey])) { $products[$pkey] = [ 'code' => $code, 'name' => $name !== '' ? $name : $code, 'm' => [], ]; for ($i = 1; $i <= 12; $i++) { $products[$pkey]['m'][$i] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0]; } } $products[$pkey]['m'][$mo]['qty'] = (int) round((float) ($row->qty ?? 0)); $products[$pkey]['m'][$mo]['amt'] = (float) ($row->amt ?? 0); $products[$pkey]['m'][$mo]['fee'] = $hasBsFee ? (float) ($row->fee ?? 0) : 0.0; } $sumMonths = static function (array $prod, array $months): array { $q = 0; $a = 0.0; $f = 0.0; foreach ($months as $mi) { $b = $prod['m'][$mi] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0]; $q += (int) ($b['qty'] ?? 0); $a += (float) ($b['amt'] ?? 0); $f += (float) ($b['fee'] ?? 0); } return [ 'qty' => $q, 'amt' => $a, 'fee' => $f, 'levy' => $a + $f, ]; }; $bucketForSpec = static function (array $prod, array $col) use ($sumMonths): array { $id = (string) ($col['id'] ?? ''); if ($id === 'total') { return $sumMonths($prod, range(1, 12)); } if (preg_match('/^m(\d{1,2})$/', $id, $m)) { $mi = (int) $m[1]; return $sumMonths($prod, [$mi]); } if ($id === 'q1') { return $sumMonths($prod, [1, 2, 3]); } if ($id === 'q2') { return $sumMonths($prod, [4, 5, 6]); } if ($id === 'q3') { return $sumMonths($prod, [7, 8, 9]); } if ($id === 'q4') { return $sumMonths($prod, [10, 11, 12]); } return ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; }; $measureDefs = [ ['key' => 'qty', 'label' => '수량'], ['key' => 'amt', 'label' => '판매금액'], ['key' => 'fee', 'label' => '수수료'], ['key' => 'levy', 'label' => '징수액'], ]; $cellsToExportRow = static function (array $cellsByCol, array $colSpecList, array $measure, bool $hasBsFee): array { $key = (string) ($measure['key'] ?? ''); $out = []; foreach ($colSpecList as $col) { $cid = (string) ($col['id'] ?? ''); $cell = $cellsByCol[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; if ($key === 'fee' && ! $hasBsFee) { $out[] = ''; } elseif ($key === 'qty') { $out[] = (string) ((int) ($cell['qty'] ?? 0)); } else { $out[] = (string) (int) round((float) ($cell[$key] ?? 0)); } } return $out; }; uasort($products, static function (array $a, array $b): int { $ca = strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? '')); if ($ca !== 0) { return $ca; } return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); }); $itemBlocks = []; $footerCells = []; foreach ($colSpec as $col) { $footerCells[(string) $col['id']] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; } foreach ($products as $prod) { $cellsByCol = []; foreach ($colSpec as $col) { $cid = (string) $col['id']; $cellsByCol[$cid] = $bucketForSpec($prod, $col); $footerCells[$cid]['qty'] += (int) $cellsByCol[$cid]['qty']; $footerCells[$cid]['amt'] += (float) $cellsByCol[$cid]['amt']; $footerCells[$cid]['fee'] += (float) $cellsByCol[$cid]['fee']; $footerCells[$cid]['levy'] += (float) $cellsByCol[$cid]['levy']; } $lines = []; foreach ($measureDefs as $md) { $lineCells = []; foreach ($colSpec as $col) { $cid = (string) $col['id']; $lineCells[$cid] = $cellsByCol[$cid]; } $lines[] = [ 'measure' => (string) ($md['label'] ?? ''), 'measureKey' => (string) ($md['key'] ?? ''), 'cells' => $lineCells, 'exportCells' => $cellsToExportRow($cellsByCol, $colSpec, $md, $hasBsFee), ]; } $itemBlocks[] = [ 'name' => (string) ($prod['name'] ?? ''), 'lines' => $lines, ]; } $footerLines = []; foreach ($measureDefs as $md) { $key = (string) ($md['key'] ?? ''); $fc = []; foreach ($colSpec as $col) { $cid = (string) $col['id']; $fc[$cid] = $footerCells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; } $footerLines[] = [ 'measure' => (string) ($md['label'] ?? ''), 'measureKey' => (string) ($md['key'] ?? ''), 'cells' => $fc, 'exportCells' => $cellsToExportRow($footerCells, $colSpec, $md, $hasBsFee), ]; } return [ 'colSpec' => $colSpec, 'itemBlocks' => $itemBlocks, 'footerBlock' => [ 'name' => '전체 합계', 'lines' => $footerLines, ], ]; } /** * P5-05: 지정판매소별 판매현황 (기간·읍면동·봉투종류·구분·수량/금액, 월별 12열, 엑셀·인쇄) */ public function shopSales() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } $zoneCode = trim((string) ($this->request->getGet('zone_code') ?? '')); $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); $metric = trim((string) ($this->request->getGet('metric') ?? 'qty')); if ($metric !== 'amt') { $metric = 'qty'; } $export = (int) ($this->request->getGet('export') ?? 0) === 1; $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); $lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : ''; $fixedGugun = $lgRow ? trim((string) ($lgRow->lg_code ?? '')) : ''; $db = \Config\Database::connect(); $zoneOptions = $this->shopSalesDistinctZoneCodes($db, $lgIdx, $fixedGugun); if ($zoneCode !== '' && ! in_array($zoneCode, $zoneOptions, true)) { $zoneCode = ''; } $bagOptions = $this->shopSalesDistinctBagCodes($db, $lgIdx); if ($bagCode !== '' && ! array_key_exists($bagCode, $bagOptions)) { $bagCode = ''; } $catLabels = [ 'general' => '일반용', 'food' => '음식물', 'sticker' => '스티커', 'reuse' => '재사용', 'apt' => '공동주택용', 'public_use' => '공공용', 'container' => '용기', 'waste' => '폐기물', ]; $isAmountMetric = $metric === 'amt'; $fifoStart = $isAmountMetric ? $startDate : min($startDate, date('Y-01-01', strtotime($endDate))); $fifoRows = $this->fetchShopSalesFifoRowsBetween( $db, $lgIdx, $fifoStart, $endDate, $zoneCode, $bagCode, $fixedGugun ); if ($isAmountMetric) { $perShop = $this->shopSalesMonthlyGrossSaleAmountByShop( $fifoRows, $startDate, $endDate, $catFilter ); } else { $perShop = $this->shopSalesFifoMonthlyByShop( $fifoRows, $startDate, $endDate, $catFilter, $metric ); if (((int) date('Y', strtotime($startDate))) === ((int) date('Y', strtotime($endDate)))) { $this->shopSalesRollOffWindowMonthsIntoFirstVisibleMonth($perShop, $startDate, $endDate); $this->shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive($perShop, $startDate, $endDate); } } $designatedShopIds = $this->shopSalesDesignatedShopIds($db, $lgIdx, $fixedGugun, $zoneCode); foreach ($designatedShopIds as $sid) { if (! isset($perShop[$sid])) { $perShop[$sid] = ['total' => 0.0]; for ($i = 1; $i <= 12; $i++) { $perShop[$sid][$i] = 0.0; } } } $reportRows = []; if ($designatedShopIds !== []) { $shops = model(DesignatedShopModel::class) ->where('ds_lg_idx', $lgIdx) ->whereIn('ds_idx', $designatedShopIds) ->orderBy('ds_shop_no', 'ASC') ->orderBy('ds_idx', 'ASC') ->findAll(); foreach ($shops as $ds) { $id = (int) ($ds->ds_idx ?? 0); if ($id <= 0) { continue; } $m = $perShop[$id] ?? null; if ($m === null) { $m = ['total' => 0.0]; for ($i = 1; $i <= 12; $i++) { $m[$i] = 0.0; } } $addrParts = array_filter([ trim((string) ($ds->ds_addr ?? '')), trim((string) ($ds->ds_addr_detail ?? '')), ], static fn (string $s): bool => $s !== ''); $reportRows[] = [ 'ds_idx' => $id, 'name' => trim((string) ($ds->ds_name ?? '')), 'rep' => trim((string) ($ds->ds_rep_name ?? '')), 'address' => implode(' ', $addrParts), 'months' => array_map(static fn (int $mi): float => (float) ($m[$mi] ?? 0.0), range(1, 12)), 'total' => (float) ($m['total'] ?? 0.0), ]; } } $grandMonths = array_fill(0, 12, 0.0); $grandTotal = 0.0; foreach ($reportRows as $rw) { foreach (range(0, 11) as $i) { $grandMonths[$i] += (float) ($rw['months'][$i] ?? 0.0); } $grandTotal += (float) ($rw['total'] ?? 0.0); } $zoneLabel = '전체'; if ($zoneCode !== '') { $zoneLabel = $zoneCode; } $bagLabel = '전체'; if ($bagCode !== '') { $bagLabel = $bagCode . (isset($bagOptions[$bagCode]) && $bagOptions[$bagCode] !== '' && $bagOptions[$bagCode] !== $bagCode ? ' (' . $bagOptions[$bagCode] . ')' : ''); } $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; $metricLabel = $metric === 'amt' ? '금액' : '수량'; if ($export) { $headers = array_merge( ['지정판매소', '대표자명', '주소', '합계'], array_map(static fn (int $m): string => $m . '월', range(1, 12)) ); $exportRows = []; foreach ($reportRows as $rw) { $line = [ (string) ($rw['name'] ?? ''), (string) ($rw['rep'] ?? ''), (string) ($rw['address'] ?? ''), $metric === 'amt' ? (string) (int) round((float) ($rw['total'] ?? 0)) : (string) (int) round((float) ($rw['total'] ?? 0)), ]; foreach (($rw['months'] ?? []) as $mv) { $line[] = $metric === 'amt' ? (string) (int) round((float) $mv) : (string) (int) round((float) $mv); } $exportRows[] = $line; } if ($reportRows !== []) { $gLine = ['전체 합계', '', '', $metric === 'amt' ? (string) (int) round($grandTotal) : (string) (int) round($grandTotal)]; foreach ($grandMonths as $gm) { $gLine[] = $metric === 'amt' ? (string) (int) round($gm) : (string) (int) round($gm); } $exportRows[] = $gLine; } $exportStamp = date('Ymd_His'); export_excel_2003_xml( '지정판매소별판매현황_' . $exportStamp, '지정판매소별', $headers, $exportRows ); } $printExtraLines = [ '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 읍면동: ' . $zoneLabel . ' · 봉투종류: ' . $bagLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $metricLabel, $metric === 'amt' ? '(단위: 원)' : '(단위: 매)', $metric === 'amt' ? '금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외).' : '수량은 반품·판매취소를 연초~조회 종료일 판매와 품목별 FIFO로 맞추고, 반품취소·판매는 원복합니다. 월별 표시는 조회기간 안만 반영하며, 조회 밖 달의 합은 기간 내 첫 달에 합산됩니다.', ]; return $this->renderWorkPage('지정 판매소별 판매현황', 'admin/sales_report/shop_sales', [ 'startDate' => $startDate, 'endDate' => $endDate, 'zoneCode' => $zoneCode, 'bagCode' => $bagCode, 'catFilter' => $catFilter, 'metric' => $metric, 'zoneOptions' => $zoneOptions, 'bagOptions' => $bagOptions, 'catLabels' => $catLabels, 'reportRows' => $reportRows, 'grandMonths' => $grandMonths, 'grandTotal' => $grandTotal, 'lgName' => $lgName, 'zoneLabel' => $zoneLabel, 'bagLabel' => $bagLabel, 'catLabelFilter' => $catLabelFilter, 'metricLabel' => $metricLabel, 'printExtraLines' => $printExtraLines, ]); } /** * 판매 집계와 무관하게, 조회 조건(지자체·구군·읍면동)에 해당하는 지정 판매소 ID 목록 * * @return list */ private function shopSalesDesignatedShopIds( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $fixedGugunCode, string $zoneCode ): array { $sql = 'SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = ? '; $params = [$lgIdx]; if ($fixedGugunCode !== '') { $sql .= ' AND ds_gugun_code = ? '; $params[] = $fixedGugunCode; } if ($zoneCode !== '') { $sql .= ' AND TRIM(ds_zone_code) = ? '; $params[] = $zoneCode; } $sql .= ' ORDER BY ds_shop_no ASC, ds_idx ASC'; $ids = []; foreach ($db->query($sql, $params)->getResult() as $row) { $id = (int) ($row->ds_idx ?? 0); if ($id > 0) { $ids[] = $id; } } return $ids; } /** * @return list */ private function shopSalesDistinctZoneCodes( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $fixedGugunCode ): array { $sql = "SELECT DISTINCT TRIM(ds_zone_code) AS z FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_zone_code) != '' "; $params = [$lgIdx]; if ($fixedGugunCode !== '') { $sql .= ' AND ds_gugun_code = ? '; $params[] = $fixedGugunCode; } $sql .= ' ORDER BY z'; $out = []; foreach ($db->query($sql, $params)->getResult() as $o) { $z = trim((string) ($o->z ?? '')); if ($z !== '') { $out[] = $z; } } return $out; } /** * @return array code => name */ private function shopSalesDistinctBagCodes(\CodeIgniter\Database\BaseConnection $db, int $lgIdx): array { $rows = $db->query( 'SELECT DISTINCT TRIM(bs_bag_code) AS c, TRIM(bs_bag_name) AS n FROM bag_sale WHERE bs_lg_idx = ? AND TRIM(bs_bag_code) != \'\' ORDER BY c LIMIT 400', [$lgIdx] )->getResult(); $map = []; foreach ($rows as $o) { $c = trim((string) ($o->c ?? '')); if ($c === '') { continue; } $map[$c] = trim((string) ($o->n ?? '')); } return $map; } /** * 지정판매소별 판매현황 FIFO용: 연초~end 또는 조회 시작 중 이른 날부터 end까지 로드(선입선출 재고 맞춤) * * @return list */ private function fetchShopSalesFifoRowsBetween( \CodeIgniter\Database\BaseConnection $db, int $lgIdx, string $startDate, string $endDate, string $zoneCode, string $bagCode, string $fixedGugunCode ): array { $sql = 'SELECT bs.bs_idx, bs.bs_ds_idx AS ds_idx, bs.bs_sale_date AS d, bs.bs_type AS t, ' . 'TRIM(bs.bs_bag_code) AS bag_code, TRIM(bs.bs_bag_name) AS bag_name, ' . 'ABS(bs.bs_qty) AS q, bs.bs_unit_price AS unit_p, ABS(bs.bs_amount) AS line_amt ' . 'FROM bag_sale bs ' . 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' . 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? ' . "AND bs.bs_type IN ('sale','return','cancel','return_cancel') "; $params = [$lgIdx, $startDate, $endDate]; if ($fixedGugunCode !== '') { $sql .= ' AND ds.ds_gugun_code = ? '; $params[] = $fixedGugunCode; } if ($zoneCode !== '') { $sql .= ' AND TRIM(ds.ds_zone_code) = ? '; $params[] = $zoneCode; } if ($bagCode !== '') { $sql .= ' AND TRIM(bs.bs_bag_code) = ? '; $params[] = $bagCode; } $sql .= 'ORDER BY bs.bs_sale_date ASC, bs.bs_idx ASC'; return $db->query($sql, $params)->getResult(); } /** * 금액 집계: 조회기간 내 sale 건의 판매금액을 거래 월별 합산(기능목록 「판매된 금액」) * * @param list $rows * * @return array> */ private function shopSalesMonthlyGrossSaleAmountByShop( array $rows, string $startDate, string $endDate, string $catFilter ): array { /** @var array> $perShop */ $perShop = []; foreach ($rows as $r) { if ((string) ($r->t ?? '') !== 'sale') { continue; } $name = (string) ($r->bag_name ?? ''); $code = (string) ($r->bag_code ?? ''); $ck = $this->classifyLedgerProductCategory($name, $code); if ($catFilter !== '' && $ck !== $catFilter) { continue; } $ds = (int) ($r->ds_idx ?? 0); if ($ds <= 0) { continue; } $dStr = (string) ($r->d ?? ''); if ($dStr < $startDate || $dStr > $endDate) { continue; } $saleMonth = (int) date('n', strtotime($dStr)); if ($saleMonth < 1 || $saleMonth > 12) { continue; } $add = $this->shopSalesResolveLineAmount($r); if ($add <= 0.0) { continue; } if (! isset($perShop[$ds])) { $perShop[$ds] = ['total' => 0.0]; for ($i = 1; $i <= 12; $i++) { $perShop[$ds][$i] = 0.0; } } $perShop[$ds][$saleMonth] += $add; $perShop[$ds]['total'] += $add; } return $perShop; } /** * @param object $row fetchShopSalesFifoRowsBetween 결과 행 */ private function shopSalesResolveLineAmount(object $row): float { $q = (float) ($row->q ?? 0); $unit = (float) ($row->unit_p ?? 0); $line = (float) ($row->line_amt ?? 0); if ($q > 0.0 && $unit > 0.0) { return $q * $unit; } return $line > 0.0 ? $line : 0.0; } /** * 판매·반품취소는 조회기간 내 월별 가산(+FIFO 적재), 반품·판매취소는 FIFO로 원판매월 차감 * * @param list $rows * * @return array> */ private function shopSalesFifoMonthlyByShop( array $rows, string $startDate, string $endDate, string $catFilter, string $metric ): array { /** @var array> $perShop */ $perShop = []; /** @var array>> $queues */ $queues = []; foreach ($rows as $r) { $name = (string) ($r->bag_name ?? ''); $code = (string) ($r->bag_code ?? ''); $ck = $this->classifyLedgerProductCategory($name, $code); if ($catFilter !== '' && $ck !== $catFilter) { continue; } $ds = (int) ($r->ds_idx ?? 0); $bag = trim((string) ($r->bag_code ?? '')); if ($ds <= 0 || $bag === '') { continue; } $dStr = (string) ($r->d ?? ''); $t = (string) ($r->t ?? ''); $q = (float) ($r->q ?? 0); if ($q <= 0.0) { continue; } $saleMonth = (int) date('n', strtotime($dStr)); if ($saleMonth < 1 || $saleMonth > 12) { continue; } $inWindow = ($dStr >= $startDate && $dStr <= $endDate); if ($t === 'sale' || $t === 'return_cancel') { $this->shopSalesFifoApplyInbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $inWindow, $metric, (float) ($r->line_amt ?? 0), (float) ($r->unit_p ?? 0)); } elseif ($t === 'return' || $t === 'cancel') { $this->shopSalesFifoApplyOutbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $metric, (float) ($r->line_amt ?? 0)); } } foreach (array_keys($perShop) as $ds) { $sum = 0.0; for ($i = 1; $i <= 12; $i++) { $sum += (float) ($perShop[$ds][$i] ?? 0.0); } $perShop[$ds]['total'] = $sum; } return $perShop; } /** * @param array> $perShop * @param array>> $queues */ private function shopSalesFifoApplyInbound( array &$perShop, array &$queues, int $ds, string $bag, float $q, int $saleMonth, bool $inWindow, string $metric, float $lineAmt, float $unitPrice ): void { if (! isset($perShop[$ds])) { $perShop[$ds] = ['total' => 0.0]; for ($i = 1; $i <= 12; $i++) { $perShop[$ds][$i] = 0.0; } } if (! isset($queues[$ds][$bag])) { $queues[$ds][$bag] = []; } $queues[$ds][$bag][] = [ 'qty' => $q, 'm' => $saleMonth, 'unit' => $unitPrice, ]; if ($inWindow) { $perShop[$ds][$saleMonth] += $q; $perShop[$ds]['total'] += $q; } } /** * @param array> $perShop * @param array>> $queues */ private function shopSalesFifoApplyOutbound( array &$perShop, array &$queues, int $ds, string $bag, float $q, int $eventMonth, string $metric, float $lineAmt ): void { if (! isset($perShop[$ds])) { $perShop[$ds] = ['total' => 0.0]; for ($i = 1; $i <= 12; $i++) { $perShop[$ds][$i] = 0.0; } } if (! isset($queues[$ds][$bag])) { $queues[$ds][$bag] = []; } $need = $q; $origRQty = $q; $origRAmt = $lineAmt; $returnMonth = $eventMonth; while ($need > 1e-9 && ($queues[$ds][$bag] ?? []) !== []) { $lot0 = &$queues[$ds][$bag][0]; $take = min($need, $lot0['qty']); $deduct = $take; $lotMonth = (int) ($lot0['m'] ?? $returnMonth); if ($lotMonth >= 1 && $lotMonth <= 12) { $perShop[$ds][$lotMonth] -= $deduct; $perShop[$ds]['total'] -= $deduct; } $need -= $take; $lot0['qty'] -= $take; if ($lot0['qty'] <= 1e-9) { array_shift($queues[$ds][$bag]); } unset($lot0); } if ($need > 1e-9) { $deduct = $need; if ($returnMonth >= 1 && $returnMonth <= 12) { $perShop[$ds][$returnMonth] -= $deduct; $perShop[$ds]['total'] -= $deduct; } } } /** * 조회기간과 겹치지 않는 달의 합계를, 겹치는 첫 달로 옮김(표시상 조회 밖 달에만 음수가 남는 것을 방지) * * @param array> $perShop */ private function shopSalesRollOffWindowMonthsIntoFirstVisibleMonth(array &$perShop, string $displayStart, string $displayEnd): void { $year = (int) date('Y', strtotime($displayStart)); if ($year !== (int) date('Y', strtotime($displayEnd))) { return; } foreach (array_keys($perShop) as $ds) { $roll = 0.0; $firstVisible = 0; for ($m = 1; $m <= 12; $m++) { $monthStart = sprintf('%04d-%02d-01', $year, $m); $monthEnd = date('Y-m-t', strtotime($monthStart)); $overlaps = ! ($monthEnd < $displayStart || $monthStart > $displayEnd); if ($overlaps) { if ($firstVisible === 0) { $firstVisible = $m; } continue; } $roll += (float) ($perShop[$ds][$m] ?? 0.0); $perShop[$ds][$m] = 0.0; } if ($firstVisible >= 1 && abs($roll) > 1e-12) { $perShop[$ds][$firstVisible] = (float) ($perShop[$ds][$firstVisible] ?? 0.0) + $roll; } $sum = 0.0; for ($i = 1; $i <= 12; $i++) { $sum += (float) ($perShop[$ds][$i] ?? 0.0); } $perShop[$ds]['total'] = $sum; } } /** * 조회 구간에 겹치는 달 중 음수는, 같은 구간 안에서 더 이른 달의 양수부터 상쇄하고 남은 부분은 마지막 달에 반영(0 미만은 0으로 맞춤) * * @param array> $perShop */ private function shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive(array &$perShop, string $displayStart, string $displayEnd): void { $year = (int) date('Y', strtotime($displayStart)); if ($year !== (int) date('Y', strtotime($displayEnd))) { return; } $visible = []; for ($m = 1; $m <= 12; $m++) { $monthStart = sprintf('%04d-%02d-01', $year, $m); $monthEnd = date('Y-m-t', strtotime($monthStart)); if (! ($monthEnd < $displayStart || $monthStart > $displayEnd)) { $visible[] = $m; } } if ($visible === []) { return; } $lastVisible = $visible[array_key_last($visible)]; foreach (array_keys($perShop) as $ds) { $need = 0.0; foreach ($visible as $m) { $v = (float) ($perShop[$ds][$m] ?? 0.0); if ($v < 0.0) { $need -= $v; $perShop[$ds][$m] = 0.0; } } if ($need <= 1e-12) { $sum = 0.0; for ($i = 1; $i <= 12; $i++) { $sum += (float) ($perShop[$ds][$i] ?? 0.0); } $perShop[$ds]['total'] = $sum; continue; } foreach ($visible as $m) { if ($need <= 1e-12) { break; } $v = (float) ($perShop[$ds][$m] ?? 0.0); if ($v <= 0.0) { continue; } $take = min($v, $need); $perShop[$ds][$m] = $v - $take; $need -= $take; } if ($need > 1e-12) { $tail = (float) ($perShop[$ds][$lastVisible] ?? 0.0) - $need; $perShop[$ds][$lastVisible] = $tail < 0.0 ? 0.0 : $tail; } $sum = 0.0; for ($i = 1; $i <= 12; $i++) { $sum += (float) ($perShop[$ds][$i] ?? 0.0); } $perShop[$ds]['total'] = $sum; } } /** * P5-06: 홈택스 처리 — 판매기간·작성일자 조회, 일괄발급 엑셀 양식 표, 엑셀·인쇄 */ public function hometaxExport() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); $writeDate = (string) ($this->request->getGet('write_date') ?? date('Y-m-d')); if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } // 최초 진입(search 미지정)도 기본 기간·작성일로 즉시 조회. 명시적으로 search=0이면 미조회. $searchGet = (string) ($this->request->getGet('search') ?? ''); $searched = $searchGet !== '0'; $export = (int) ($this->request->getGet('export') ?? 0) === 1; if ($export && ! $searched) { return redirect()->to(mgmt_url('reports/hometax-export?' . http_build_query([ 'start_date' => $startDate, 'end_date' => $endDate, 'write_date' => $writeDate, 'search' => '1', 'export' => '1', ]))); } $lg = model(LocalGovernmentModel::class)->find($lgIdx); if (! $lg) { return redirect()->to(work_area_home_url())->with('error', '지자체 정보를 찾을 수 없습니다.'); } $lgName = trim((string) ($lg->lg_name ?? '')); $headers = $this->hometaxBulkColumnHeaders(); $writeYmd = str_replace('-', '', $writeDate); /** @var list $rows */ $rows = []; $totalCount = 0; $totalSupplyAmount = 0.0; $totalTaxAmount = 0.0; $missingBizCount = 0; if ($searched) { $db = \Config\Database::connect(); $rows = $db->query( 'SELECT bs.bs_sale_date, bs.bs_bag_name, ABS(bs.bs_qty) AS qty, bs.bs_unit_price, bs.bs_amount, ' . 'ds.ds_biz_no, ds.ds_name, ds.ds_rep_name, ds.ds_branch_no, ' . 'ds.ds_addr, ds.ds_addr_detail, ds.ds_biz_type, ds.ds_biz_kind, ds.ds_email ' . 'FROM bag_sale bs ' . 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' . 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = \'sale\' ' . 'ORDER BY bs.bs_sale_date ASC, ds.ds_name ASC, bs.bs_idx ASC', [$lgIdx, $startDate, $endDate] )->getResult(); $totalCount = count($rows); foreach ($rows as $r) { $amt = (float) ($r->bs_amount ?? 0); $totalSupplyAmount += $amt; $totalTaxAmount += (int) round($amt * 0.1); if ($this->hometaxBuyerBizMissing((string) ($r->ds_biz_no ?? ''))) { $missingBizCount++; } } } $displayRows = []; foreach ($rows as $r) { $displayRows[] = $this->hometaxBuildBulkRow($lg, $r, $writeYmd); } $hometaxPrintPages = $this->hometaxPrintPageDefinitions(count($headers)); if ($export) { export_excel_2003_xml_workbook( '홈택스처리_' . date('Ymd_His'), $this->hometaxBuildPrintStyleExcelSheets($headers, $displayRows, $hometaxPrintPages) ); } $printExtraLines = [ '판매일자: ' . $startDate . ' ~ ' . $endDate . ' · 작성일자: ' . $writeDate, '엑셀·인쇄는 동일한 2쪽 열 구성(1쪽 공급자·공급받는자, 2쪽 금액·품목)입니다.', ]; return $this->renderWorkPage('홈택스 처리', 'admin/sales_report/hometax_process', [ 'startDate' => $startDate, 'endDate' => $endDate, 'writeDate' => $writeDate, 'searched' => $searched, 'headers' => $headers, 'displayRows' => $displayRows, 'hometaxPrintPages' => $hometaxPrintPages, 'hometaxColMinPx' => $this->hometaxColumnMinWidthsPx(), 'totalCount' => $totalCount, 'totalSupplyAmount' => $totalSupplyAmount, 'totalTaxAmount' => $totalTaxAmount, 'missingBizCount' => $missingBizCount, 'lgName' => $lgName, 'printExtraLines' => $printExtraLines, ]); } /** * 인쇄·엑셀 공통: 1쪽(앞 14열) / 2쪽(식별 3열 + 나머지) * * @return list}> */ private function hometaxPrintPageDefinitions(int $colCount): array { if ($colCount <= 0) { return []; } $splitEnd = 13; $pages = [ [ 'label' => '1쪽 — 전자세금계산서·공급자·공급받는자', 'sheet_name' => '1쪽 공급자공급받는자', 'cols' => range(0, min($splitEnd, $colCount - 1)), ], ]; if ($splitEnd + 1 < $colCount) { $page2Cols = [0, 1, 2]; for ($i = $splitEnd + 1; $i < $colCount; $i++) { $page2Cols[] = $i; } $pages[] = [ 'label' => '2쪽 — 식별정보·금액·품목', 'sheet_name' => '2쪽 금액품목', 'cols' => $page2Cols, ]; } return $pages; } /** * @return list */ private function hometaxColumnMinWidthsPx(): array { return [ 52, 72, 72, 88, 48, 80, 56, 140, 48, 48, 100, 88, 48, 80, 56, 140, 48, 48, 100, 72, 72, 48, 72, 48, 48, 56, 56, 56, ]; } /** * @param list $headers * @param list> $displayRows * @param list> $printPages * * @return list, rows: list>, col_widths: list}> */ private function hometaxBuildPrintStyleExcelSheets(array $headers, array $displayRows, array $printPages): array { $minPx = $this->hometaxColumnMinWidthsPx(); $sheets = []; foreach ($printPages as $page) { /** @var list $cols */ $cols = array_values((array) ($page['cols'] ?? [])); if ($cols === []) { continue; } $sheetHeaders = []; $colWidths = []; foreach ($cols as $ci) { $sheetHeaders[] = (string) ($headers[$ci] ?? ''); $colWidths[] = (int) ($minPx[$ci] ?? 72); } $sheetRows = []; foreach ($displayRows as $row) { $line = []; foreach ($cols as $ci) { $line[] = (string) ($row[$ci] ?? ''); } $sheetRows[] = $line; } $sheets[] = [ 'name' => (string) ($page['sheet_name'] ?? 'Sheet'), 'headers' => $sheetHeaders, 'rows' => $sheetRows, 'col_widths' => $colWidths, ]; } return $sheets; } /** * 홈택스 일괄발급 엑셀과 동일한 열 제목(순서 고정) * * @return list */ private function hometaxBulkColumnHeaders(): array { return [ '전자세금계산서종류', '작성일자', '공급일자', '공급자등록번호', '공급자종사업장번호', '공급자상호', '공급자성명', '공급자사업장주소', '공급자업태', '공급자종목', '공급자이메일1', '공급받는자사업자등록번호', '공급받는자종사업장번호', '공급받는자상호', '공급받는자성명', '공급받는자사업장주소', '공급받는자업태', '공급받는자종목', '공급받는자이메일1', '공급가액합계', '세액합계', '영수청구구분', '품목명', '규격', '수량', '단가', '공급가액', '세액', '비고', ]; } private function hometaxOnlyDigits(?string $s): string { return (string) preg_replace('/\D+/', '', (string) ($s ?? '')); } private function hometaxBuyerBizMissing(string $raw): bool { $d = $this->hometaxOnlyDigits($raw); return $d === '' || strlen($d) < 10; } /** * @return list */ private function hometaxBuildBulkRow(object $lg, object $r, string $writeYmd): array { $supplyYmd = str_replace('-', '', (string) ($r->bs_sale_date ?? '')); $amount = (int) round((float) ($r->bs_amount ?? 0)); $tax = (int) round($amount * 0.1); $qty = (int) ($r->qty ?? 0); $unit = (int) round((float) ($r->bs_unit_price ?? 0)); $supplierBiz = $this->hometaxOnlyDigits((string) ($lg->lg_biz_no ?? '')); $buyerBiz = $this->hometaxOnlyDigits((string) ($r->ds_biz_no ?? '')); $buyerAddr = trim(trim((string) ($r->ds_addr ?? '')) . ' ' . trim((string) ($r->ds_addr_detail ?? ''))); return [ '01', $writeYmd, $supplyYmd, $supplierBiz, '', trim((string) ($lg->lg_name ?? '')), '', trim((string) ($lg->lg_addr ?? '')), '', '', '', $buyerBiz, trim((string) ($r->ds_branch_no ?? '')), trim((string) ($r->ds_name ?? '')), trim((string) ($r->ds_rep_name ?? '')), $buyerAddr, trim((string) ($r->ds_biz_type ?? '')), trim((string) ($r->ds_biz_kind ?? '')), trim((string) ($r->ds_email ?? '')), (string) $amount, (string) $tax, '02', trim((string) ($r->bs_bag_name ?? '')), '', (string) $qty, (string) $unit, (string) $amount, (string) $tax, '', ]; } /** * P5-08: 반품/파기 현황 (레거시 sm805r) */ public function returns() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $queried = $this->request->getGet('search') === '1'; $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } $ioType = (string) ($this->request->getGet('io_type') ?? 'out'); if (! in_array($ioType, ['in', 'out'], true)) { $ioType = 'out'; } $result = $queried ? $this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType) : []; return $this->renderWorkPage('반품/파기 현황', 'admin/sales_report/returns', [ 'result' => $result, 'startDate' => $startDate, 'endDate' => $endDate, 'ioType' => $ioType, 'queried' => $queried, ]); } public function returnsExport() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.'); } $startDate = trim((string) ($this->request->getGet('start_date') ?? '')); $endDate = trim((string) ($this->request->getGet('end_date') ?? '')); $hasQuery = $this->request->getGet('search') === '1' || ($startDate !== '' && $endDate !== ''); if (! $hasQuery) { return redirect()->to(mgmt_url('reports/returns'))->with('error', '조회 후 엑셀 저장을 이용해 주세요.'); } if ($startDate === '') { $startDate = date('Y-m-01'); } if ($endDate === '') { $endDate = date('Y-m-d'); } if ($startDate > $endDate) { [$startDate, $endDate] = [$endDate, $startDate]; } $ioType = (string) ($this->request->getGet('io_type') ?? 'out'); if (! in_array($ioType, ['in', 'out'], true)) { $ioType = 'out'; } $rows = []; foreach ($this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType) as $row) { $rows[] = [ (string) $row->bs_sale_date, (string) $row->bs_ds_name, $this->returnDisposeKindLabel($row), (string) (int) $row->qty, $this->returnDisposeTypeLabel((string) $row->bs_type), ]; } $ioLabel = $ioType === 'in' ? '입고' : '출고'; export_excel_2003_xml( '반품파기현황_' . $startDate . '_' . $endDate . '.xls', '반품파기현황', ['일자', '반품처', '종류', '수량', '구분'], $rows ); return null; } /** * 출고 = 지정판매소 반품(designated-return · bag_return_scan_code) * 입고 = 물류 입고분 파기(bag_dispose) * * @return list */ private function fetchReturnDisposeRows(int $lgIdx, string $startDate, string $endDate, string $ioType): array { $db = \Config\Database::connect(); if ($ioType === 'out') { return $this->fetchDesignatedReturnRows($db, $lgIdx, $startDate, $endDate); } return $this->fetchBagDisposeRows($db, $lgIdx, $startDate, $endDate); } /** * @return list */ private function fetchDesignatedReturnRows($db, int $lgIdx, string $startDate, string $endDate): array { if ($db->tableExists('bag_return_scan_code')) { return $db->query(" SELECT r.brsc_return_date AS bs_sale_date, COALESCE(ds.ds_name, '') AS bs_ds_name, r.brsc_bag_code AS bs_bag_code, r.brsc_bag_name AS bs_bag_name, 'return' AS bs_type, SUM(r.brsc_qty) AS qty FROM bag_return_scan_code r LEFT JOIN designated_shop ds ON ds.ds_idx = r.brsc_ds_idx AND ds.ds_lg_idx = r.brsc_lg_idx WHERE r.brsc_lg_idx = ? AND r.brsc_return_date BETWEEN ? AND ? AND r.brsc_state = 'returned' GROUP BY r.brsc_return_date, r.brsc_ds_idx, ds.ds_name, r.brsc_bag_code, r.brsc_bag_name ORDER BY r.brsc_return_date ASC, bs_ds_name ASC, r.brsc_bag_code ASC ", [$lgIdx, $startDate, $endDate])->getResult(); } return $db->query(" SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type, ABS(bs_qty) AS qty FROM bag_sale WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type = 'return' ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC ", [$lgIdx, $startDate, $endDate])->getResult(); } /** * @return list */ private function fetchBagDisposeRows($db, int $lgIdx, string $startDate, string $endDate): array { if (! $db->tableExists('bag_dispose')) { return []; } return $db->query(" SELECT bd_dispose_date AS bs_sale_date, bd_location AS bs_ds_name, bd_bag_code AS bs_bag_code, bd_bag_name AS bs_bag_name, 'dispose' AS bs_type, bd_qty AS qty FROM bag_dispose WHERE bd_lg_idx = ? AND bd_dispose_date BETWEEN ? AND ? ORDER BY bd_dispose_date ASC, bd_location ASC, bd_bag_code ASC ", [$lgIdx, $startDate, $endDate])->getResult(); } private function returnDisposeKindLabel(object $row): string { $name = trim((string) ($row->bs_bag_name ?? '')); $code = trim((string) ($row->bs_bag_code ?? '')); if ($name !== '') { return $name; } return $code !== '' ? $code : '-'; } private function returnDisposeTypeLabel(string $bsType): string { return match ($bsType) { 'return' => '반품', 'dispose' => '파기', 'cancel' => '파기', default => $bsType, }; } /** * P5-10: LOT 수불 조회 (레거시 w_gd033a — 바코드/봉투번호) */ public function lotFlow() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $barcode = trim((string) ($this->request->getGet('barcode') ?? $this->request->getGet('bag_no') ?? '')); $lotNo = trim((string) ($this->request->getGet('lot_no') ?? '')); $queried = $this->request->getGet('search') === '1' || $barcode !== '' || $lotNo !== ''; $builder = new \App\Libraries\BagLotFlowBuilder(); if ($barcode !== '') { $result = $builder->buildByBarcode($lgIdx, $barcode, true); } elseif ($lotNo !== '') { $result = $builder->buildByLotNo($lgIdx, $lotNo, true); $barcode = $lotNo; } else { $result = $builder->buildByBarcode($lgIdx, '', $queried); } return $this->renderWorkPage('LOT 수불 조회', 'admin/sales_report/lot_flow', [ 'barcode' => $barcode, 'lotNo' => $lotNo, 'queried' => $queried, 'result' => $result, 'testSamples' => $builder->loadTestSamples($lgIdx), ]); } /** * P5-11: 기타 입출고 (레거시 w_gb401e — 수불년월·구분·마스터/상세) */ public function miscFlow() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request); $flowYear = $flowPeriod['y']; $flowMonthNum = $flowPeriod['m']; $bagCodeFilter = trim((string) ($this->request->getGet('bag_code') ?? '')); $bagKind = trim((string) ($this->request->getGet('bag_kind') ?? '')); $bagCancelOnly = (int) ($this->request->getGet('bag_cancel') ?? 0) === 1; $selKey = trim((string) ($this->request->getGet('sel_key') ?? '')); $db = \Config\Database::connect(); $tableExists = $db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0; $kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagKindOptions = $kindE ? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, $lgIdx) : []; $bagCodes = $kindO ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : []; $groups = []; $rawRows = []; $totalRowsForLg = 0; if ($tableExists) { $totalRow = $db->query( 'SELECT COUNT(*) AS c FROM bag_misc_flow WHERE bmf_lg_idx = ?', [$lgIdx] )->getRow(); $totalRowsForLg = (int) ($totalRow->c ?? 0); $rawRows = $this->miscFlowFetchRows($db, $lgIdx, $flowYear, $flowMonthNum); foreach ($rawRows as $row) { if ($bagCodeFilter !== '' && ! str_contains((string) $row->bmf_bag_code, $bagCodeFilter)) { continue; } if ($bagKind !== '' && ! $this->miscFlowBagCodeMatchesKind((string) $row->bmf_bag_code, $bagKind)) { continue; } if ($bagCancelOnly && (string) $row->bmf_type !== 'out') { continue; } $key = $this->miscFlowGroupKey($row); if (! isset($groups[$key])) { $groups[$key] = [ 'key' => $key, 'date' => (string) $row->bmf_date, 'type' => (string) $row->bmf_type, 'typeLabel' => (string) $row->bmf_type === 'in' ? '입고' : '출고', 'reason' => (string) $row->bmf_reason, 'totalQty' => 0, 'lines' => [], ]; } $groups[$key]['totalQty'] += (int) $row->bmf_qty; $groups[$key]['lines'][] = $row; } } $groupList = array_values($groups); $selectedGroup = null; $detailLines = []; $selectedBagKind = ''; $selectedBagKindLabel = ''; if ($groupList !== []) { if ($selKey !== '' && isset($groups[$selKey])) { $selectedGroup = $groups[$selKey]; } else { $selectedGroup = $groupList[0]; $selKey = (string) $selectedGroup['key']; } $detailLines = $selectedGroup['lines']; if ($detailLines !== []) { $selectedBagKind = $this->miscFlowInferBagKindFromCode((string) $detailLines[0]->bmf_bag_code); foreach ($bagKindOptions as $opt) { if ((string) $opt->cd_code === $selectedBagKind) { $selectedBagKindLabel = (string) $opt->cd_name; break; } } } } $packagingMap = model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx); $filters = [ 'flow_y' => $flowYear, 'flow_m' => $flowMonthNum, 'bag_code' => $bagCodeFilter, 'bag_kind' => $bagKind, 'bag_cancel' => $bagCancelOnly, 'sel_key' => $selKey, ]; return $this->renderWorkPage('기타 입출고', 'admin/sales_report/misc_flow', [ 'groupList' => $groupList, 'selectedGroup' => $selectedGroup, 'detailLines' => $detailLines, 'filters' => $filters, 'dateYearMin' => (int) date('Y') - 12, 'dateYearMax' => (int) date('Y') + 2, 'bagCodes' => $bagCodes, 'bagKindOptions' => $bagKindOptions, 'packagingMap' => $packagingMap, 'selectedBagKind' => $selectedBagKind, 'selectedBagKindLabel' => $selectedBagKindLabel, 'tableExists' => $tableExists, 'totalRowsForLg' => $totalRowsForLg, 'fetchedRowCount' => count($rawRows), ]); } /** * P5-11: 기타 입출고 품목 등록 */ public function miscFlowStore() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.'); } $rules = [ 'bmf_type' => 'required|in_list[in,out]', 'bmf_bag_code' => 'required|max_length[50]', 'bmf_qty' => 'required|is_natural_no_zero', 'bmf_date' => 'required|valid_date[Y-m-d]', 'bmf_reason' => 'required|max_length[200]', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $bagCode = (string) $this->request->getPost('bmf_bag_code'); $bagKindFilter = trim((string) $this->request->getPost('bmf_bag_kind')); if ($bagKindFilter !== '' && ! $this->miscFlowBagCodeMatchesKind($bagCode, $bagKindFilter)) { return redirect()->back()->withInput()->with('error', '선택한 봉투구분과 봉투코드가 일치하지 않습니다.'); } $qty = (int) $this->request->getPost('bmf_qty'); $type = (string) $this->request->getPost('bmf_type'); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx) : null; $bagName = $detail ? (string) $detail->cd_name : ''; $db = \Config\Database::connect(); if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) { return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.'); } $db->transStart(); $db->query(" INSERT INTO bag_misc_flow (bmf_lg_idx, bmf_type, bmf_bag_code, bmf_bag_name, bmf_qty, bmf_date, bmf_reason, bmf_regdate) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ", [ $lgIdx, $type, $bagCode, $bagName, $qty, $this->request->getPost('bmf_date'), $this->request->getPost('bmf_reason'), date('Y-m-d H:i:s'), ]); $delta = ($type === 'in') ? $qty : -$qty; model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $delta); $db->transComplete(); $redirectQs = $this->miscFlowRedirectQueryFromPost(); $newKey = $this->miscFlowGroupKey((object) [ 'bmf_date' => $this->request->getPost('bmf_date'), 'bmf_type' => $type, 'bmf_reason' => $this->request->getPost('bmf_reason'), ]); return redirect()->to(mgmt_url('reports/misc-flow') . '?' . $redirectQs . '&sel_key=' . rawurlencode($newKey)) ->with('success', '기타 입출고가 등록되었습니다.'); } /** * P5-11: 선택 건(그룹) 삭제 + 재고 복원 */ public function miscFlowDelete() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.'); } $selKey = trim((string) $this->request->getPost('sel_key')); if ($selKey === '') { return redirect()->back()->with('error', '삭제할 입출고를 선택해 주세요.'); } $db = \Config\Database::connect(); if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) { return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.'); } $flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request); $rows = $this->miscFlowFetchRows($db, $lgIdx, $flowPeriod['y'], $flowPeriod['m']); $toDelete = []; foreach ($rows as $row) { if ($this->miscFlowGroupKey($row) === $selKey) { $toDelete[] = $row; } } if ($toDelete === []) { return redirect()->back()->with('error', '삭제할 내역을 찾을 수 없습니다.'); } $inventory = model(BagInventoryModel::class); $db->transStart(); foreach ($toDelete as $row) { $qty = (int) $row->bmf_qty; $delta = ((string) $row->bmf_type === 'in') ? -$qty : $qty; $inventory->adjustQty( $lgIdx, (string) $row->bmf_bag_code, (string) $row->bmf_bag_name, $delta ); $db->query('DELETE FROM bag_misc_flow WHERE bmf_idx = ? AND bmf_lg_idx = ?', [(int) $row->bmf_idx, $lgIdx]); } $db->transComplete(); $redirectQs = $this->miscFlowRedirectQueryFromPost(false); return redirect()->to(mgmt_url('reports/misc-flow') . ($redirectQs !== '' ? '?' . $redirectQs : '')) ->with('success', '선택한 기타 입출고가 삭제되었습니다.'); } /** * @return array{y: string, m: string} */ private function miscFlowParsePeriodFromRequest($request): array { $flowYear = trim((string) ($request->getGet('flow_y') ?? $request->getPost('flow_y') ?? '')); $flowMonthNum = trim((string) ($request->getGet('flow_m') ?? $request->getPost('flow_m') ?? '')); if ($flowYear === '' && $flowMonthNum === '') { $legacy = $this->miscFlowNormalizeMonth((string) ($request->getGet('flow_month') ?? $request->getPost('flow_month') ?? '')); if ($legacy !== '' && preg_match('/^(\d{4})-(\d{1,2})$/', $legacy, $m) === 1) { $flowYear = $m[1]; $flowMonthNum = (string) (int) $m[2]; } } if ($flowYear !== '' && ctype_digit($flowYear)) { $flowYear = sprintf('%04d', (int) $flowYear); } else { $flowYear = ''; } if ($flowMonthNum !== '' && ctype_digit($flowMonthNum)) { $monthInt = (int) $flowMonthNum; $flowMonthNum = ($monthInt >= 1 && $monthInt <= 12) ? (string) $monthInt : ''; } else { $flowMonthNum = ''; } if ($flowYear === '') { $flowMonthNum = ''; } return ['y' => $flowYear, 'm' => $flowMonthNum]; } private function miscFlowNormalizeMonth(string $raw): string { $raw = str_replace('.', '-', trim($raw)); if ($raw === '') { return ''; } if (preg_match('/^\d{4}-\d{1,2}$/', $raw) === 1) { [$y, $m] = explode('-', $raw, 2); return sprintf('%04d-%02d', (int) $y, (int) $m); } if (preg_match('/^\d{6}$/', $raw) === 1) { return substr($raw, 0, 4) . '-' . substr($raw, 4, 2); } return ''; } /** * @param \CodeIgniter\Database\BaseConnection $db * @return list */ private function miscFlowFetchRows($db, int $lgIdx, string $flowYear, string $flowMonthNum): array { if ($flowYear !== '' && $flowMonthNum !== '') { $monthStart = sprintf('%04d-%02d-01', (int) $flowYear, (int) $flowMonthNum); $monthEnd = date('Y-m-t', strtotime($monthStart)); return $db->query(" SELECT * FROM bag_misc_flow WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ? ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC ", [$lgIdx, $monthStart, $monthEnd])->getResult(); } if ($flowYear !== '') { $yearStart = sprintf('%04d-01-01', (int) $flowYear); $yearEnd = sprintf('%04d-12-31', (int) $flowYear); return $db->query(" SELECT * FROM bag_misc_flow WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ? ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC ", [$lgIdx, $yearStart, $yearEnd])->getResult(); } return $db->query(" SELECT * FROM bag_misc_flow WHERE bmf_lg_idx = ? ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC ", [$lgIdx])->getResult(); } private function miscFlowGroupKey(object $row): string { $date = (string) ($row->bmf_date ?? ''); $type = (string) ($row->bmf_type ?? ''); $reason = (string) ($row->bmf_reason ?? ''); return md5($date . '|' . $type . '|' . $reason); } private function miscFlowInferBagKindFromCode(string $bagCode): string { $bagCode = trim($bagCode); if ($bagCode === '') { return ''; } if (strlen($bagCode) >= 2 && ctype_digit(substr($bagCode, 0, 2))) { return substr($bagCode, 0, 2); } return ''; } private function miscFlowBagCodeMatchesKind(string $bagCode, string $bagKind): bool { if ($bagKind === '') { return true; } return str_starts_with($bagCode, $bagKind); } private function miscFlowRedirectQueryFromPost(bool $includeSelKey = true): string { $params = array_filter([ 'flow_y' => $this->request->getPost('flow_y') ?? $this->request->getGet('flow_y'), 'flow_m' => $this->request->getPost('flow_m') ?? $this->request->getGet('flow_m'), 'bag_code' => $this->request->getPost('bag_code') ?? $this->request->getGet('bag_code'), 'bag_kind' => $this->request->getPost('bag_kind') ?? $this->request->getGet('bag_kind'), 'bag_cancel' => $this->request->getPost('bag_cancel') ?? $this->request->getGet('bag_cancel'), ], static fn ($v) => $v !== null && $v !== '' && $v !== '0'); if ($includeSelKey) { $sel = $this->request->getPost('sel_key'); if ($sel !== null && $sel !== '') { $params['sel_key'] = $sel; } } return http_build_query($params); } /** * 쓰레기봉투 수급 계획 (레거시 w_gm820r) */ public function supplyDemand() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } $refDate = (string) ($this->request->getGet('ref_date') ?? date('Y-m-d')); if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $refDate)) { $refDate = date('Y-m-d'); } $leadDays = (int) ($this->request->getGet('lead_days') ?? 40); if ($leadDays < 1 || $leadDays > 365) { $leadDays = 40; } $stockScope = (string) ($this->request->getGet('stock_scope') ?? 'all'); $salesScope = (string) ($this->request->getGet('sales_scope') ?? 'all'); if (! in_array($stockScope, ['all', 'legacy', 'barcode'], true)) { $stockScope = 'all'; } if (! in_array($salesScope, ['all', 'legacy', 'barcode'], true)) { $salesScope = 'all'; } $queried = $this->request->getGet('search') === '1'; $built = (new \App\Libraries\BagSupplyPlanBuilder())->build( $lgIdx, $refDate, $leadDays, $stockScope, $salesScope, $queried ); $scopeLabel = static fn (string $s): string => match ($s) { 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투', default => 'ALL', }; return $this->renderWorkPage('쓰레기봉투 수급 계획', 'admin/sales_report/supply_demand', [ 'refDate' => $refDate, 'leadDays' => $leadDays, 'stockScope' => $stockScope, 'salesScope' => $salesScope, 'rows' => $built['rows'], 'queried' => $built['queried'], 'stockLabel' => $scopeLabel($stockScope), 'salesLabel' => $scopeLabel($salesScope), ]); } }