<?php

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Models\BagIssueModel;
use App\Models\BagReceivingModel;
use App\Models\BagInventoryModel;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
use App\Models\PackagingUnitModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use App\Models\SalesAgencyModel;

class SalesReport extends BaseController
{
    /**
     * P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
     */
    public function salesLedger()
    {
        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'));
        $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;
            }
        }

        // 크기별(리터/금액) 조회 — 품목명에서 크기 토큰 추출(예: 10L, 1000원)
        $sizeOf = static function (string $name): string {
            if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
                return $m[1] . 'L';
            }
            if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
                return str_replace(',', '', $m[1]) . '원';
            }
            return '';
        };
        // 봉투(일반용)/음식물/폐기물 등 카테고리별로 선택 가능한 크기 목록을 구성한다.
        // (카테고리 필터까지 적용된 현재 결과 기준)
        $sizeCatLabels = [
            'general'    => '일반용 봉투',
            'reuse'      => '재사용 봉투',
            'public_use' => '공공용 봉투',
            'food'       => '음식물 봉투',
            'waste'      => '폐기물 봉투',
        ];
        // 스티커·공동주택용(apt)·용기(container)는 판매대장 크기 필터에서 제외
        // 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
        $sizeCatOrder = ['general', 'reuse', 'public_use', 'food', 'waste'];

        $sizeByCat = []; // catKey => [size => true]
        foreach ($filtered as $row) {
            $sz = $sizeOf((string) ($row->bs_bag_name ?? ''));
            if ($sz === '') {
                continue;
            }
            $ck = $this->classifyLedgerProductCategory(
                (string) ($row->bs_bag_name ?? ''),
                (string) ($row->bs_bag_code ?? '')
            );
            $sizeByCat[$ck][$sz] = true;
        }
        $sizeGroups = [];
        foreach ($sizeCatOrder as $ck) {
            if (empty($sizeByCat[$ck])) {
                continue;
            }
            $sizes = array_keys($sizeByCat[$ck]);
            usort($sizes, static function (string $a, string $b): int {
                return (int) preg_replace('/\D/', '', $a) <=> (int) preg_replace('/\D/', '', $b);
            });
            $sizeGroups[] = ['key' => $ck, 'label' => $sizeCatLabels[$ck] ?? $ck, 'sizes' => $sizes];
        }

        // 선택값은 "카테고리|크기" 형식(예: general|10L). 구버전(크기만) 값도 호환.
        $size = trim((string) ($this->request->getGet('size') ?? ''));
        if ($size !== '') {
            if (strpos($size, '|') !== false) {
                [$selCat, $selSize] = array_pad(explode('|', $size, 2), 2, '');
                $filtered = array_values(array_filter($filtered, function ($row) use ($sizeOf, $selCat, $selSize): bool {
                    $ck = $this->classifyLedgerProductCategory(
                        (string) ($row->bs_bag_name ?? ''),
                        (string) ($row->bs_bag_code ?? '')
                    );
                    return $ck === $selCat && $sizeOf((string) ($row->bs_bag_name ?? '')) === $selSize;
                }));
            } else {
                $filtered = array_values(array_filter($filtered, static fn ($row): bool => $sizeOf((string) ($row->bs_bag_name ?? '')) === $size));
            }
        }

        // 하단 통계: 현재 필터(카테고리·크기)까지 적용된 결과 기준으로
        // ① 용량별(리터별) ② 품목별(봉투/음식물/폐기물) 집계
        [$sizeStats, $catStats] = $this->buildLedgerBottomStats($filtered, $sizeOf);

        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,
            'sizeGroups'        => $sizeGroups,
            'size'              => $size,
            'sizeStats'         => $sizeStats,
            'catStats'          => $catStats,
            'shops'             => $shops,
            'agencies'          => $agencies,
            'lgName'            => $lgName,
            'filterShopLabel'   => $shopLabel,
            'filterAgencyLabel' => $agencyLabel,
            'printSubtitleLines' => $printSubtitleLines,
        ]);
    }

    /**
     * @return list<object>
     */
    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<object> $rows   필터가 적용된 상세 판매행
     * @param callable      $sizeOf 품목명 → 크기 토큰(예: '10L', '1000원', '')
     * @return array{0: list<array{label:string,qty:int,amount:int}>, 1: list<array{label:string,qty:int,amount:int}>}
     */
    private function buildLedgerBottomStats(array $rows, callable $sizeOf): array
    {
        $bySize = []; // 크기토큰 => [qty, amount]
        $byCat  = []; // '봉투'|'음식물'|'폐기물' => [qty, amount]

        foreach ($rows as $row) {
            $name = (string) ($row->bs_bag_name ?? '');
            $qty  = (int) ($row->line_qty ?? 0);
            $amt  = (float) ($row->line_amount ?? 0);

            // ① 용량별(리터별) — 크기 토큰이 없으면 '기타'
            $sz = (string) $sizeOf($name);
            if ($sz === '') {
                $sz = '기타';
            }
            $bySize[$sz]['qty']    = ($bySize[$sz]['qty'] ?? 0) + $qty;
            $bySize[$sz]['amount'] = ($bySize[$sz]['amount'] ?? 0) + $amt;

            // ② 품목별 — 봉투/음식물/폐기물 3분류
            if (mb_strpos($name, '음식물') !== false) {
                $cat = '음식물';
            } elseif (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
                $cat = '폐기물';
            } else {
                $cat = '봉투';
            }
            $byCat[$cat]['qty']    = ($byCat[$cat]['qty'] ?? 0) + $qty;
            $byCat[$cat]['amount'] = ($byCat[$cat]['amount'] ?? 0) + $amt;
        }

        // 용량별: 숫자(리터/금액) 오름차순, '기타'는 맨 뒤
        $sizeStats = [];
        foreach ($bySize as $label => $v) {
            $sizeStats[] = ['label' => (string) $label, 'qty' => (int) $v['qty'], 'amount' => (int) round($v['amount'])];
        }
        usort($sizeStats, static function (array $a, array $b): int {
            $ao = $a['label'] === '기타' ? 1 : 0;
            $bo = $b['label'] === '기타' ? 1 : 0;
            if ($ao !== $bo) {
                return $ao <=> $bo;
            }
            return (int) preg_replace('/\D/', '', $a['label']) <=> (int) preg_replace('/\D/', '', $b['label']);
        });

        // 품목별: 봉투 → 음식물 → 폐기물 고정 순서
        $catStats = [];
        foreach (['봉투', '음식물', '폐기물'] as $c) {
            if (isset($byCat[$c])) {
                $catStats[] = ['label' => $c, 'qty' => (int) $byCat[$c]['qty'], 'amount' => (int) round($byCat[$c]['amount'])];
            }
        }

        return [$sizeStats, $catStats];
    }

    /**
     * @param list<string> $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<object> $rows
     * @return array{rows: list<array<string,mixed>>, 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<object> $rows
     * @return array{rows: list<array<string,mixed>>, 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<object>
     */
    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<object>
     */
    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<object> $rawRows
     *
     * @return array{lines: list<array<string,mixed>>, foot_all: array<string,int|float>, foot_bag: array<string,int|float>, foot_fs: array<string,int|float>}
     */
    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-d', strtotime('-7 days')));
        $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']));
            $colCount = count($headers);

            // 최상단 제목 블록: (지역/범위) ……… (단위 : 매/원) / (제목) ……… 출력일
            $region = trim(
                (string) ($lgRow->lg_sido ?? '') . ' ' . (string) ($lgRow->lg_gugun ?? '')
            );
            if ($region === '') {
                $region = $lgName;
            }
            $makeTopRow = static function (string $left, string $right) use ($colCount): array {
                $row = array_fill(0, max($colCount, 1), '');
                $row[0] = $left;
                if ($right !== '') {
                    $row[max($colCount - 1, 0)] = $right;
                }

                return $row;
            };
            $topRows = [
                $makeTopRow($region . ' / ' . $gugunLabel, '(단위 : 매/원)'),
                $makeTopRow($year . '년 판매현황 · 대행소: ' . $agencyLabel, '출력일: ' . date('Y-m-d')),
                array_fill(0, max($colCount, 1), ''), // 구분용 빈 줄
            ];

            $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,
                $topRows
            );
        }

        $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<array{id: string, label: string}>
     */
    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<array{code: string, name: string}>
     */
    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<object>
     */
    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<object> $monthRows
     *
     * @return array{
     *   colSpec: list<array{id: string, label: string}>,
     *   itemBlocks: list<array{name: string, lines: list<array{measure: string, cells: array<string, array{qty: int, amt: float, fee: float, levy: float}>, exportCells: list<string>}>>},
     *   footerBlock: array{name: string, lines: list<array{measure: string, cells: array<string, array{qty: int, amt: float, fee: float, levy: float}>, exportCells: list<string>}>}
     * }
     */
    private function buildYearlySalesPresentation(array $monthRows, bool $hasBsFee): array
    {
        $colSpec = $this->yearlySalesColumnSpec();

        /** @var array<string, array{code: string, name: string, m: array<int, array{qty: int, amt: float, fee: float}>}> $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<int>
     */
    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<string>
     */
    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<string, string> 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<object>
     */
    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<object> $rows
     *
     * @return array<int, array<int|float>>
     */
    private function shopSalesMonthlyGrossSaleAmountByShop(
        array $rows,
        string $startDate,
        string $endDate,
        string $catFilter
    ): array {
        /** @var array<int, array<int|float>> $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<object> $rows
     *
     * @return array<int, array<int|float>>
     */
    private function shopSalesFifoMonthlyByShop(
        array $rows,
        string $startDate,
        string $endDate,
        string $catFilter,
        string $metric
    ): array {
        /** @var array<int, array<int|float>> $perShop */
        $perShop = [];
        /** @var array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $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<int, array<int|float>> $perShop
     * @param array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $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<int, array<int|float>> $perShop
     * @param array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $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<int, array<int|float>> $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<int, array<int|float>> $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<object> $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<array{label: string, sheet_name: string, cols: list<int>}>
     */
    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<int>
     */
    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<string>              $headers
     * @param list<list<string>>        $displayRows
     * @param list<array<string,mixed>> $printPages
     *
     * @return list<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths: list<int>}>
     */
    private function hometaxBuildPrintStyleExcelSheets(array $headers, array $displayRows, array $printPages): array
    {
        $minPx = $this->hometaxColumnMinWidthsPx();
        $sheets = [];

        foreach ($printPages as $page) {
            /** @var list<int> $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<string>
     */
    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<string>
     */
    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', '지자체를 선택해 주세요.');
        }

        // 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·불출)으로 자동 조회해 바로 표시한다.
        $queried = ($this->request->getGet('search') ?? '1') === '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<object>
     */
    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<object>
     */
    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<object>
     */
    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<object>
     */
    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';
        }

        // 첫 진입(search 파라미터 없음)에도 기본 조건(오늘·40일·ALL)으로 자동 조회해 바로 표시한다.
        $queried = ($this->request->getGet('search') ?? '1') === '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),
        ]);
    }
}
