<?php

declare(strict_types=1);

namespace App\Controllers;

use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\BagInventoryModel;
use App\Models\BagIssueModel;
use App\Models\BagIssueItemCodeModel;
use App\Models\BagOrderModel;
use App\Models\BagOrderItemModel;
use App\Models\BagPriceModel;
use App\Models\BagReceivingModel;
use App\Models\BagSaleModel;
use App\Models\CodeKindModel;
use App\Models\CodeDetailModel;
use App\Models\CompanyModel;
use App\Models\PackagingUnitModel;
use App\Models\SalesAgencyModel;
use App\Models\ShopOrderModel;
use App\Models\ShopOrderItemModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use App\Models\ManagerModel;
use App\Libraries\BagAnalyticsReportBuilder;
use App\Libraries\BagFlowReportBuilder;
use Config\Roles;

class Bag extends BaseController
{
    /**
     * 로그인 사용자의 지자체 PK 반환 (미로그인/미지정 시 null)
     */
    private function lgIdx(): ?int
    {
        helper('admin');
        return admin_effective_lg_idx();
    }

    /**
     * 통계 분석: 사이트 메뉴와 동일하게 작업 지자체 PK (Super Admin 미선택 시 기본 지자체).
     */
    private function analyticsLgIdx(): int
    {
        helper('admin');

        return resolve_site_menu_lg_idx();
    }

    /**
     * [개발용 패널] DB naive datetime 을 한국 표준시로 변환해 JSON 에 내려준다.
     * MySQL TIMESTAMP/UTC 세션 등으로 `Y-m-d H:i:s` 가 UTC 기준일 때, 그대로 찍히면 현지 시각과 9시간 어긋난다.
     */
    private function formatDevPanelEventTime(?string $dbValue): string
    {
        if ($dbValue === null) {
            return '';
        }
        $trim = trim($dbValue);
        if ($trim === '' || str_starts_with($trim, '0000-00-00')) {
            return '';
        }
        try {
            $utc = new \DateTimeImmutable($trim, new \DateTimeZone('UTC'));

            return $utc->setTimezone(new \DateTimeZone('Asia/Seoul'))->format('Y-m-d H:i:s');
        } catch (\Throwable) {
            return $trim;
        }
    }

    /**
     * 입고 화면용 인계자: 제작업체(company) 담당자.
     *
     * @return array{senders: list<object>, defaultSenderIdx: int}
     */
    private function receivingManagerPickers(int $lgIdx): array
    {
        $senders = model(ManagerModel::class, false)
            ->where('mg_lg_idx', $lgIdx)
            ->where('mg_state', 1)
            ->where('mg_dept_code', 'company')
            ->orderBy('mg_name', 'ASC')
            ->findAll();

        $sessionName = trim((string) (session()->get('mb_name') ?? ''));
        $defaultSenderIdx = 0;
        foreach ($senders as $s) {
            if ((string) ($s->mg_name ?? '') === $sessionName) {
                $defaultSenderIdx = (int) ($s->mg_idx ?? 0);
                break;
            }
        }
        if ($defaultSenderIdx <= 0 && $senders !== []) {
            $defaultSenderIdx = (int) ($senders[0]->mg_idx ?? 0);
        }

        return [
            'senders' => $senders,
            'defaultSenderIdx' => $defaultSenderIdx,
        ];
    }

    /**
     * 인수자 드롭다운: 맨 위에 현재 로그인 회원, 이어서 대행소(agency) 담당자.
     * value 는 br_receiver_ref 로 전달: m_{mb_idx} | g_{mg_idx}
     *
     * @return array{receiverOptions: list<array{ref: string, label: string}>, defaultReceiverRef: string}
     */
    private function receivingReceiverSelect(int $lgIdx): array
    {
        $sessionMbIdx = (int) (session()->get('mb_idx') ?? 0);
        $sessionName = trim((string) (session()->get('mb_name') ?? ''));
        $normalizeName = static fn (string $name): string => preg_replace('/\s+/u', '', trim($name)) ?? '';
        $normSession = $normalizeName($sessionName);

        $options = [];
        if ($sessionMbIdx > 0) {
            $label = $sessionName !== '' ? $sessionName : '로그인 사용자';
            $options[] = ['ref' => 'm_' . $sessionMbIdx, 'label' => $label];
        }

        $agencyManagers = model(ManagerModel::class, false)
            ->where('mg_lg_idx', $lgIdx)
            ->where('mg_state', 1)
            ->where('mg_dept_code', 'agency')
            ->orderBy('mg_name', 'ASC')
            ->findAll();

        foreach ($agencyManagers as $rcv) {
            $mgIdx = (int) ($rcv->mg_idx ?? 0);
            $receiverName = trim((string) ($rcv->mg_name ?? ''));
            if ($mgIdx <= 0) {
                continue;
            }
            if ($normSession !== '' && $normalizeName($receiverName) === $normSession) {
                continue;
            }
            $options[] = ['ref' => 'g_' . $mgIdx, 'label' => $receiverName];
        }

        $defaultRef = $options !== [] ? (string) ($options[0]['ref'] ?? '') : '';

        return [
            'receiverOptions' => $options,
            'defaultReceiverRef' => $defaultRef,
        ];
    }

    /**
     * @param list<array{ref: string, label: string}> $options
     */
    private function sanitizeReceiverRef(array $options, string $ref): string
    {
        foreach ($options as $opt) {
            if (($opt['ref'] ?? '') === $ref) {
                return $ref;
            }
        }

        return '';
    }

    private function parseReceiverRefToStoredIdx(int $lgIdx, string $ref): int
    {
        $ref = trim($ref);
        if (preg_match('/^m_(\d+)$/', $ref, $mm)) {
            $mbIdx = (int) $mm[1];
            if ($mbIdx <= 0 || $mbIdx !== (int) (session()->get('mb_idx') ?? 0)) {
                return 0;
            }

            return $mbIdx;
        }
        if (preg_match('/^g_(\d+)$/', $ref, $mg)) {
            return $this->assertAgencyReceiverIdx($lgIdx, (int) $mg[1]);
        }

        return 0;
    }

    private function assertAgencyReceiverIdx(int $lgIdx, int $mgIdx): int
    {
        if ($mgIdx <= 0) {
            return 0;
        }
        $row = model(ManagerModel::class, false)->where([
            'mg_idx' => $mgIdx,
            'mg_lg_idx' => $lgIdx,
            'mg_state' => 1,
            'mg_dept_code' => 'agency',
        ])->first();

        return $row ? $mgIdx : 0;
    }

    private function resolveCompanySenderName(int $lgIdx, int $mgIdx): string
    {
        if ($mgIdx <= 0) {
            return '';
        }
        $row = model(ManagerModel::class, false)->where([
            'mg_idx' => $mgIdx,
            'mg_lg_idx' => $lgIdx,
            'mg_state' => 1,
            'mg_dept_code' => 'company',
        ])->first();

        return $row ? trim((string) ($row->mg_name ?? '')) : '';
    }

    private function render(string $title, string $viewFile, array $data = [], bool $bare = false): string
    {
        // /workspace 탭(iframe) 안에서는 임베드 레이아웃(헤더·사이드바 없이 본문만).
        $layout = $this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal';

        return view($layout, [
            'title'   => $title,
            'content' => view($viewFile, $data),
            'bare'    => $bare, // true면 바깥 카드 래퍼 없이 본문을 그대로(이미 카드형 화면용)
        ]);
    }

    // ──────────────────────────────────────────────
    // 기본정보관리 (단가·포장 단위 진입 허브)
    // ──────────────────────────────────────────────
    public function basicInfo(): string
    {
        return $this->render('기본정보관리', 'bag/basic_info', []);
    }

    /** 봉투 단가 조회 (사이트) — 기간·봉투구분·봉투코드 필터, 적용기간 겹침, 페이징·인쇄 */
    public function prices(): string|RedirectResponse
    {
        helper('admin');
        if ($this->request->is('post')) {
            $post = $this->request->getPost();
            $pick = static function (array $src, string $key): ?string {
                if (! array_key_exists($key, $src)) {
                    return null;
                }
                $v = $src[$key];
                if ($v === null || is_array($v)) {
                    return null;
                }
                $s = trim((string) $v);

                return $s === '' ? null : $s;
            };
            session()->setFlashdata('bag_prices_filter', [
                'start_y'    => $pick($post, 'start_y'),
                'start_m'    => $pick($post, 'start_m'),
                'start_d'    => $pick($post, 'start_d'),
                'end_y'      => $pick($post, 'end_y'),
                'end_m'      => $pick($post, 'end_m'),
                'end_d'      => $pick($post, 'end_d'),
                'bag_kind_e' => $pick($post, 'bag_kind_e'),
                'bag_code'   => $pick($post, 'bag_code'),
            ]);

            return redirect()->to(site_url('bag/prices'));
        }

        $lgIdx     = $this->lgIdx();
        $bagPrices = [];

        $get   = $this->request->getGet();
        $flash = session()->getFlashdata('bag_prices_filter');

        $readSrc = static function (array $src, string $key): ?string {
            if (! array_key_exists($key, $src)) {
                return null;
            }
            $v = $src[$key];
            if ($v === null || is_array($v)) {
                return null;
            }
            $s = trim((string) $v);

            return $s === '' ? null : $s;
        };

        $filterKeys = [
            'start_y', 'start_m', 'start_d',
            'end_y', 'end_m', 'end_d',
            'bag_kind_e', 'bag_code',
            'start_date', 'end_date',
        ];
        $hasExplicitGetFilter = false;
        foreach ($filterKeys as $fk) {
            $v = $get[$fk] ?? null;
            if ($v !== null && ! is_array($v) && trim((string) $v) !== '') {
                $hasExplicitGetFilter = true;
                break;
            }
        }

        $src = [];
        if ($hasExplicitGetFilter) {
            $src = $get;
        } elseif (is_array($flash)) {
            $src = $flash;
        }

        $sy = $readSrc($src, 'start_y');
        $sm = $readSrc($src, 'start_m');
        $sd = $readSrc($src, 'start_d');
        $ey = $readSrc($src, 'end_y');
        $em = $readSrc($src, 'end_m');
        $ed = $readSrc($src, 'end_d');

        $startDate = null;
        if ($sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '') {
            $startDate = parse_ymd_from_triple($sy, $sm, $sd);
        }
        if ($startDate === null) {
            $g = $readSrc($src, 'start_date');
            $startDate = ($g !== null && $g !== '') ? $g : null;
        }

        $endDate = null;
        if ($ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '') {
            $endDate = parse_ymd_from_triple($ey, $em, $ed);
        }
        if ($endDate === null) {
            $g = $readSrc($src, 'end_date');
            $endDate = ($g !== null && $g !== '') ? $g : null;
        }

        $startParts = ['y' => '', 'm' => '', 'd' => ''];
        $endParts   = ['y' => '', 'm' => '', 'd' => ''];
        if ($startDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $startDate, $m)) {
            $startParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
        } elseif ($sy !== null && $sm !== null && $sd !== null && $sy !== '' && $sm !== '' && $sd !== '') {
            $iy = (int) $sy;
            $im = (int) $sm;
            $id = (int) $sd;
            if ($iy >= 1000 && $iy <= 9999 && $im >= 1 && $im <= 12 && $id >= 1 && $id <= 31) {
                $startParts = ['y' => (string) $iy, 'm' => $im, 'd' => $id];
            }
        }
        if ($endDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $endDate, $m)) {
            $endParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
        } elseif ($ey !== null && $em !== null && $ed !== null && $ey !== '' && $em !== '' && $ed !== '') {
            $iy = (int) $ey;
            $im = (int) $em;
            $id = (int) $ed;
            if ($iy >= 1000 && $iy <= 9999 && $im >= 1 && $im <= 12 && $id >= 1 && $id <= 31) {
                $endParts = ['y' => (string) $iy, 'm' => $im, 'd' => $id];
            }
        }

        $dateYearMin = (int) date('Y') - 12;
        $dateYearMax = (int) date('Y') + 2;

        $bagKindE = $readSrc($src, 'bag_kind_e');
        $bagCode  = $readSrc($src, 'bag_code');
        $pager       = null;
        $bagCodes    = [];
        $bagKindOpts = [];
        $printLines  = [];
        $printLgName = '';

        if ($lgIdx !== null) {
            try {
                $priceModel = model(BagPriceModel::class);
                $builder    = $priceModel->where('bp_lg_idx', $lgIdx);

                if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
                    $qStart = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
                    $qEnd   = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
                    if (strcmp((string) $qStart, (string) $qEnd) > 0) {
                        [$qStart, $qEnd] = [$qEnd, $qStart];
                    }
                    $builder->where('bp_start_date <=', $qEnd);
                    $builder->groupStart()
                        ->where('bp_end_date IS NULL')
                        ->orWhere('bp_end_date >=', $qStart)
                    ->groupEnd();
                }

                if ($bagKindE !== null && $bagKindE !== '') {
                    $ek = model(CodeKindModel::class)->where('ck_code', 'E')->first();
                    if ($ek) {
                        $eDetail = model(CodeDetailModel::class)
                            ->where('cd_ck_idx', (int) $ek->ck_idx)
                            ->where('cd_code', $bagKindE)
                            ->where('cd_state', 1)
                            ->first();
                        if ($eDetail !== null) {
                            $builder->like('bp_bag_code', (string) $bagKindE, 'after');
                        }
                    }
                }

                if ($bagCode !== null && $bagCode !== '') {
                    $ok = model(CodeKindModel::class)->where('ck_code', 'O')->first();
                    if ($ok) {
                        $oDetail = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $ok->ck_idx, (string) $bagCode, $lgIdx);
                        if ($oDetail !== null) {
                            $builder->where('bp_bag_code', $bagCode);
                        }
                    }
                }

                $bagPrices = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->paginate(20);

                $queryForPager = [];
                $tripleS         = $sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '';
                $tripleE         = $ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '';
                if ($tripleS) {
                    $queryForPager['start_y'] = $sy;
                    $queryForPager['start_m'] = $sm;
                    $queryForPager['start_d'] = $sd;
                } else {
                    $legacyS = $readSrc($src, 'start_date');
                    if ($legacyS !== null) {
                        $queryForPager['start_date'] = $legacyS;
                    }
                }
                if ($tripleE) {
                    $queryForPager['end_y'] = $ey;
                    $queryForPager['end_m'] = $em;
                    $queryForPager['end_d'] = $ed;
                } else {
                    $legacyE = $readSrc($src, 'end_date');
                    if ($legacyE !== null) {
                        $queryForPager['end_date'] = $legacyE;
                    }
                }
                if ($bagKindE !== null && $bagKindE !== '') {
                    $queryForPager['bag_kind_e'] = $bagKindE;
                }
                if ($bagCode !== null && $bagCode !== '') {
                    $queryForPager['bag_code'] = $bagCode;
                }
                $queryForPager = array_filter(
                    $queryForPager,
                    static fn ($v) => $v !== null && $v !== ''
                );
                apply_pager_path($priceModel->pager, 'bag/prices', $queryForPager);
                $pager = $priceModel->pager;

                $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
                $bagCodes = $kindO
                    ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx)
                    : [];

                $kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
                $bagKindOpts = $kindE
                    ? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null)
                    : [];

                $lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
                $printLgName = $lgRow !== null ? $lgRow->lg_name : '';
            } catch (DatabaseException $e) {
                log_message('error', '[prices] bag_price 조회 실패: ' . $e->getMessage());
            }
        }

        if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
            $qs = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
            $qe = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
            if (strcmp((string) $qs, (string) $qe) > 0) {
                [$qs, $qe] = [$qe, $qs];
            }
            $printLines[] = '조회기간(적용기간 겹침): ' . format_ymd_korean($qs) . ' ~ ' . format_ymd_korean($qe);
        }
        if ($bagKindE !== null && $bagKindE !== '') {
            foreach ($bagKindOpts as $cd) {
                if ((string) $cd->cd_code === (string) $bagKindE) {
                    $printLines[] = '봉투구분: ' . $cd->cd_name . ' (' . $bagKindE . ')';
                    break;
                }
            }
        }
        if ($bagCode !== null && $bagCode !== '') {
            $printLines[] = '봉투코드: ' . $bagCode;
        }

        $viewData = [
            'lgIdx'            => $lgIdx,
            'bagPrices'        => $bagPrices,
            'pager'            => $pager,
            'startDate'        => $startDate,
            'endDate'          => $endDate,
            'startParts'       => $startParts,
            'endParts'         => $endParts,
            'dateYearMin'      => $dateYearMin,
            'dateYearMax'      => $dateYearMax,
            'bag_kind_e'       => $bagKindE,
            'bag_code'         => $bagCode,
            'bag_codes'        => $bagCodes,
            'bag_kind_options' => $bagKindOpts,
            'printExtraLines'  => $printLines,
        ];
        if ($printLgName !== '') {
            $viewData['printLgName'] = $printLgName;
        }

        return $this->render('봉투 단가', 'bag/prices', $viewData);
    }

    /** 포장 단위 조회 (사이트) */
    public function packagingUnits(): string
    {
        $lgIdx           = $this->lgIdx();
        $packagingUnits = [];
        if ($lgIdx) {
            try {
                $packagingUnits = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->orderBy('pu_bag_code', 'ASC')->findAll();
            } catch (DatabaseException $e) {
                log_message('error', '[packagingUnits] packaging_unit 조회 실패: ' . $e->getMessage());
            }
        }

        return $this->render('포장 단위', 'bag/packaging_units', ['packagingUnits' => $packagingUnits]);
    }

    /**
     * 기본코드 종류·세부코드 조회 전용 (사이트 메뉴 기본코드관리)
     */
    public function codeKinds(): string
    {
        $kindModel   = model(CodeKindModel::class);
        $detailModel = model(CodeDetailModel::class);
        $kinds       = [];
        $countMap    = [];
        $selectedKind = null;
        $detailList   = [];
        $rowCanEdit   = [];
        $lgIdx       = $this->lgIdx();
        try {
            $kinds = $kindModel->orderBy('ck_code', 'ASC')->findAll();
            foreach ($kinds as $row) {
                $countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)
                    ->filterByTenantScope($lgIdx)
                    ->countAllResults();
            }
        } catch (\Throwable $e) {
            log_message('error', '[codeKinds] 실패: {type} {message} @ {file}:{line} / lg={lg}, user={user}, level={level}', [
                'type'    => $e::class,
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'lg'      => $lgIdx !== null ? (string) $lgIdx : 'null',
                'user'    => (string) (session()->get('mb_id') ?? ''),
                'level'   => (string) (session()->get('mb_level') ?? ''),
            ]);
            session()->setFlashdata('error', '기본코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
        }

        $level = (int) session()->get('mb_level');
        $canManageDetails = Roles::canManageCodeMaster($level);

        if ($kinds !== []) {
            $selectedCkIdx = (int) ($this->request->getGet('ck_idx') ?? 0);
            foreach ($kinds as $row) {
                if ((int) $row->ck_idx === $selectedCkIdx) {
                    $selectedKind = $row;
                    break;
                }
            }
            if ($selectedKind === null) {
                $selectedKind = $kinds[0];
            }
        }

        if ($selectedKind !== null) {
            $detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx)
                ->filterByTenantScope($lgIdx)
                ->orderBy('cd_sort', 'ASC')
                ->orderBy('cd_code', 'ASC')
                ->orderBy('cd_idx', 'ASC')
                ->findAll();

            helper('admin');
            $adminLg = admin_effective_lg_idx();
            foreach ($detailList as $row) {
                $rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLg);
            }
        }

        return $this->render('기본코드관리', 'bag/code_kinds', [
            'codeKinds'      => $kinds,
            'countMap'       => $countMap,
            'canManageKinds' => Roles::canManageCodeKindMaster($level),
            'canManageDetails' => $canManageDetails,
            'selectedKind'   => $selectedKind,
            'detailList'     => $detailList,
            'rowCanEdit'     => $rowCanEdit,
            'isEmbed'        => $this->isEmbeddedRequest(), // 행 클릭 시 embed 유지(중첩 셸 방지)
        ], true); // 본문이 이미 카드 2개라 바깥 래퍼 생략
    }

    /**
     * 기본코드 세부 목록 (사이트 레이아웃). 등록·수정·삭제 폼은 /admin/code-details/* 유지.
     */
    public function codeDetails(int $ckIdx)
    {
        $kindModel   = model(CodeKindModel::class);
        $detailModel = model(CodeDetailModel::class);
        $kind        = null;
        try {
            $kind = $kindModel->find($ckIdx);
        } catch (\Throwable $e) {
            log_message('error', '[codeDetails] kind 조회 실패: {type} {message} @ {file}:{line} / ck={ck}, user={user}, level={level}', [
                'type'    => $e::class,
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'ck'      => (string) $ckIdx,
                'user'    => (string) (session()->get('mb_id') ?? ''),
                'level'   => (string) (session()->get('mb_level') ?? ''),
            ]);
            return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
        }
        if ($kind === null) {
            return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
        }

        $lgIdx = $this->lgIdx();
        try {
            $list  = $detailModel->where('cd_ck_idx', $ckIdx)
                ->filterByTenantScope($lgIdx)
                ->orderBy('cd_sort', 'ASC')
                ->orderBy('cd_code', 'ASC')
                ->orderBy('cd_idx', 'ASC')
                ->paginate(20);
            $pager = $detailModel->pager;
        } catch (\Throwable $e) {
            log_message('error', '[codeDetails] list 조회 실패: {type} {message} @ {file}:{line} / ck={ck}, lg={lg}, user={user}, level={level}', [
                'type'    => $e::class,
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'ck'      => (string) $ckIdx,
                'lg'      => $lgIdx !== null ? (string) $lgIdx : 'null',
                'user'    => (string) (session()->get('mb_id') ?? ''),
                'level'   => (string) (session()->get('mb_level') ?? ''),
            ]);
            return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
        }

        helper('admin');
        $level      = (int) session()->get('mb_level');
        $adminLg    = admin_effective_lg_idx();
        $canManage  = Roles::canManageCodeMaster($level);
        $rowCanEdit = [];
        foreach ($list as $row) {
            $rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLg);
        }

        $title = ($canManage ? '세부코드 관리' : '세부코드 조회') . ' — ' . $kind->ck_name . ' (' . $kind->ck_code . ')';

        return $this->render($title, 'bag/code_details', [
            'kind'       => $kind,
            'list'       => $list,
            'pager'      => $pager,
            'canManage'  => $canManage,
            'rowCanEdit' => $rowCanEdit,
        ]);
    }

    // ──────────────────────────────────────────────
    // 발주 입고 관리
    // ──────────────────────────────────────────────
    public function purchaseInbound(): string
    {
        $lgIdx = $this->lgIdx();
        $data = ['orders' => [], 'receivings' => [], 'startDate' => null, 'endDate' => null];

        if ($lgIdx) {
            $startDate = $this->request->getGet('start_date');
            $endDate   = $this->request->getGet('end_date');
            $data['startDate'] = $startDate;
            $data['endDate']   = $endDate;

            // 발주 목록
            $orderBuilder = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx);
            if ($startDate) $orderBuilder->where('bo_order_date >=', $startDate);
            if ($endDate)   $orderBuilder->where('bo_order_date <=', $endDate);
            $data['orders'] = $orderBuilder->orderBy('bo_order_date', 'DESC')->paginate(20, 'orders');
            $data['orderPager'] = model(BagOrderModel::class)->pager;

            // 발주별 품목 합계
            $itemSummary = [];
            foreach ($data['orders'] as $order) {
                $items = model(BagOrderItemModel::class)->where('boi_bo_idx', $order->bo_idx)->findAll();
                $totalQty = 0;
                $totalAmt = 0;
                foreach ($items as $it) {
                    $totalQty += (int) $it->boi_qty_sheet;
                    $totalAmt += (float) $it->boi_amount;
                }
                $itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
            }
            $data['itemSummary'] = $itemSummary;

            // 입고 목록
            $recvBuilder = model(BagReceivingModel::class)->where('br_lg_idx', $lgIdx);
            if ($startDate) $recvBuilder->where('br_receive_date >=', $startDate);
            if ($endDate)   $recvBuilder->where('br_receive_date <=', $endDate);
            $data['receivings'] = $recvBuilder->orderBy('br_receive_date', 'DESC')->paginate(20, 'receivings');
            $data['recvPager'] = model(BagReceivingModel::class)->pager;
        }

        return $this->render('발주 입고 관리', 'bag/purchase_inbound', $data);
    }

    // ──────────────────────────────────────────────
    // 불출 관리
    // ──────────────────────────────────────────────
    public function issueLegacy(): RedirectResponse
    {
        return redirect()->to(site_url('bag/issue/cancel'));
    }

    public function issue(): string
    {
        $lgIdx = $this->lgIdx();
        $data = [
            'filters' => [
                'issue_month' => (string) ($this->request->getGet('issue_month') ?? ''),
                'dest_name' => (string) ($this->request->getGet('dest_name') ?? ''),
                'issue_type' => (string) ($this->request->getGet('issue_type') ?? ''),
                'bag_code' => (string) ($this->request->getGet('bag_code') ?? ''),
            ],
            'monthOptions' => [],
            'destOptions' => [],
            'typeOptions' => ['무료용', '공공용'],
            'bagOptions' => [],
            'issueGroups' => [],
            'detailRows' => [],
            'detailSourceRows' => [],
            'codeRows' => [],
            'selectedGroupDate' => '',
            'selectedGroupDest' => '',
            'selectedIssueId' => 0,
            'selectedBagCode' => '',
        ];

        if (! $lgIdx) {
            return $this->render('불출 관리', 'bag/issue', $data);
        }

        $db = \Config\Database::connect();
        $issueTable = $db->table('bag_issue');
        $hasItemCodeTable = $db->tableExists('bag_issue_item_code');

        $filterMonth = trim((string) $data['filters']['issue_month']);
        $filterDest = trim((string) $data['filters']['dest_name']);
        $filterType = trim((string) $data['filters']['issue_type']);
        $filterBag = trim((string) $data['filters']['bag_code']);

        $applyCommonFilters = static function ($builder) use ($lgIdx, $filterMonth, $filterDest, $filterType, $filterBag): void {
            $builder->where('bi2_lg_idx', $lgIdx);
            if (preg_match('/^\d{4}-\d{2}$/', $filterMonth) === 1) {
                $start = $filterMonth . '-01';
                $end = date('Y-m-t', strtotime($start));
                $builder->where('bi2_issue_date >=', $start);
                $builder->where('bi2_issue_date <=', $end);
            }
            if ($filterDest !== '') {
                $builder->where('bi2_dest_name', $filterDest);
            }
            if ($filterType !== '') {
                $builder->where('bi2_issue_type', $filterType);
            }
            if ($filterBag !== '') {
                $builder->where('bi2_bag_code', $filterBag);
            }
        };

        $monthRows = $db->table('bag_issue')
            ->select("DATE_FORMAT(bi2_issue_date, '%Y-%m') AS issue_month", false)
            ->where('bi2_lg_idx', $lgIdx)
            ->groupBy("DATE_FORMAT(bi2_issue_date, '%Y-%m')", false)
            ->orderBy('issue_month', 'DESC')
            ->get()
            ->getResultArray();
        foreach ($monthRows as $row) {
            $month = (string) ($row['issue_month'] ?? '');
            if ($month !== '') {
                $data['monthOptions'][] = $month;
            }
        }

        $destRows = $db->table('bag_issue')
            ->select('bi2_dest_name')
            ->where('bi2_lg_idx', $lgIdx)
            ->groupBy('bi2_dest_name')
            ->orderBy('bi2_dest_name', 'ASC')
            ->get()
            ->getResultArray();
        $data['destOptions'] = array_values(array_filter(array_map(static fn ($r): string => (string) ($r['bi2_dest_name'] ?? ''), $destRows)));

        $bagRows = $db->table('bag_issue')
            ->select('bi2_bag_code, MAX(bi2_bag_name) AS bi2_bag_name', false)
            ->where('bi2_lg_idx', $lgIdx)
            ->groupBy('bi2_bag_code')
            ->orderBy('bi2_bag_code', 'ASC')
            ->get()
            ->getResultArray();
        foreach ($bagRows as $row) {
            $code = (string) ($row['bi2_bag_code'] ?? '');
            if ($code === '') {
                continue;
            }
            $data['bagOptions'][] = [
                'code' => $code,
                'name' => (string) ($row['bi2_bag_name'] ?? $code),
            ];
        }

        $groupBuilder = $db->table('bag_issue')
            ->select('bi2_issue_date, bi2_dest_name, COUNT(*) AS row_count, SUM(bi2_qty) AS total_qty', false)
            ->groupBy('bi2_issue_date, bi2_dest_name');
        $applyCommonFilters($groupBuilder);
        $data['issueGroups'] = $groupBuilder
            ->orderBy('bi2_issue_date', 'DESC')
            ->orderBy('bi2_dest_name', 'ASC')
            ->get()
            ->getResultArray();

        $selectedDate = (string) ($this->request->getGet('sel_date') ?? '');
        $selectedDest = (string) ($this->request->getGet('sel_dest') ?? '');
        if (($selectedDate === '' || $selectedDest === '') && $data['issueGroups'] !== []) {
            $selectedDate = (string) ($data['issueGroups'][0]['bi2_issue_date'] ?? '');
            $selectedDest = (string) ($data['issueGroups'][0]['bi2_dest_name'] ?? '');
        }
        $data['selectedGroupDate'] = $selectedDate;
        $data['selectedGroupDest'] = $selectedDest;

        if ($selectedDate !== '' && $selectedDest !== '') {
            $detailBuilder = $db->table('bag_issue')
                ->select('bi2_idx, bi2_issue_date, bi2_issue_type, bi2_bag_code, bi2_bag_name, bi2_qty, bi2_status')
                ->where('bi2_lg_idx', $lgIdx)
                ->where('bi2_issue_date', $selectedDate)
                ->where('bi2_dest_name', $selectedDest);
            if ($filterType !== '') {
                $detailBuilder->where('bi2_issue_type', $filterType);
            }
            if ($filterBag !== '') {
                $detailBuilder->where('bi2_bag_code', $filterBag);
            }
            $data['detailRows'] = $detailBuilder
                ->orderBy('bi2_idx', 'ASC')
                ->get()
                ->getResultArray();
            $data['detailSourceRows'] = $data['detailRows'];
        }

        $detailIssueIds = array_map(static fn ($row): int => (int) ($row['bi2_idx'] ?? 0), $data['detailRows']);
        $cancelMap = [];
        $codeQtyMap = [];
        if ($hasItemCodeTable && $detailIssueIds !== []) {
            $aggRows = $db->table('bag_issue_item_code')
                ->select('bic_bi2_idx, SUM(bic_qty) AS sum_qty, SUM(bic_cancel_qty) AS sum_cancel', false)
                ->whereIn('bic_bi2_idx', $detailIssueIds)
                ->groupBy('bic_bi2_idx')
                ->get()
                ->getResultArray();
            foreach ($aggRows as $agg) {
                $idx = (int) ($agg['bic_bi2_idx'] ?? 0);
                if ($idx <= 0) {
                    continue;
                }
                $cancelMap[$idx] = (int) ($agg['sum_cancel'] ?? 0);
                $codeQtyMap[$idx] = (int) ($agg['sum_qty'] ?? 0);
            }
        }
        foreach ($data['detailRows'] as &$row) {
            $idx = (int) ($row['bi2_idx'] ?? 0);
            $cancelQty = (int) ($cancelMap[$idx] ?? 0);
            $baseQty = isset($codeQtyMap[$idx]) ? (int) $codeQtyMap[$idx] : ((int) ($row['bi2_qty'] ?? 0) + $cancelQty);
            $row['base_qty'] = max(0, $baseQty);
            $row['cancel_qty'] = max(0, min($row['base_qty'], $cancelQty));
            $row['remain_qty'] = max(0, $row['base_qty'] - $row['cancel_qty']);
        }
        unset($row);
        $data['detailSourceRows'] = $data['detailRows'];

        $aggByBag = [];
        foreach ($data['detailRows'] as $row) {
            $bagCodeKey = (string) ($row['bi2_bag_code'] ?? '');
            if ($bagCodeKey === '') {
                continue;
            }
            if (! isset($aggByBag[$bagCodeKey])) {
                $aggByBag[$bagCodeKey] = [
                    'bi2_issue_date' => (string) ($row['bi2_issue_date'] ?? ''),
                    'bi2_issue_type' => (string) ($row['bi2_issue_type'] ?? ''),
                    'bi2_bag_code' => $bagCodeKey,
                    'bi2_bag_name' => (string) ($row['bi2_bag_name'] ?? $bagCodeKey),
                    'base_qty' => 0,
                    'cancel_qty' => 0,
                    'issue_ids' => [],
                ];
            }
            $aggByBag[$bagCodeKey]['base_qty'] += (int) ($row['base_qty'] ?? 0);
            $aggByBag[$bagCodeKey]['cancel_qty'] += (int) ($row['cancel_qty'] ?? 0);
            $aggByBag[$bagCodeKey]['issue_ids'][] = (int) ($row['bi2_idx'] ?? 0);
        }
        $data['detailRows'] = array_values($aggByBag);

        $selectedIssueId = (int) ($this->request->getGet('sel_issue_id') ?? 0);
        if ($selectedIssueId <= 0 && $data['detailRows'] !== []) {
            $selectedIssueId = (int) (($data['detailRows'][0]['issue_ids'][0] ?? 0));
        }
        $data['selectedIssueId'] = $selectedIssueId;

        $selectedBagCode = trim((string) ($this->request->getGet('sel_bag_code') ?? ''));
        if ($selectedBagCode === '' && $data['detailRows'] !== []) {
            $selectedBagCode = (string) ($data['detailRows'][0]['bi2_bag_code'] ?? '');
        }
        $data['selectedBagCode'] = $selectedBagCode;

        if ($selectedBagCode !== '') {
            $selectedIssueIds = [];
            foreach (($data['detailRows'] ?? []) as $detailRow) {
                if ((string) ($detailRow['bi2_bag_code'] ?? '') !== $selectedBagCode) {
                    continue;
                }
                $selectedIssueIds = array_values(array_filter(array_map('intval', (array) ($detailRow['issue_ids'] ?? []))));
                break;
            }

            $sourceByIssue = [];
            foreach (($data['detailSourceRows'] ?? []) as $sourceRow) {
                if ((string) ($sourceRow['bi2_bag_code'] ?? '') !== $selectedBagCode) {
                    continue;
                }
                $sourceIssueId = (int) ($sourceRow['bi2_idx'] ?? 0);
                if ($sourceIssueId <= 0) {
                    continue;
                }
                $sourceByIssue[$sourceIssueId] = $sourceRow;
            }

            if ($hasItemCodeTable) {
                if ($selectedIssueIds !== []) {
                    $existingRows = $db->table('bag_issue_item_code')
                        ->select('bic_bi2_idx')
                        ->where('bic_lg_idx', $lgIdx)
                        ->where('bic_bag_code', $selectedBagCode)
                        ->whereIn('bic_bi2_idx', $selectedIssueIds)
                        ->groupBy('bic_bi2_idx')
                        ->get()
                        ->getResultArray();
                    $existingIssueSet = [];
                    foreach ($existingRows as $existingRow) {
                        $issueId = (int) ($existingRow['bic_bi2_idx'] ?? 0);
                        if ($issueId > 0) {
                            $existingIssueSet[$issueId] = true;
                        }
                    }
                    foreach ($selectedIssueIds as $issueId) {
                        $issueId = (int) $issueId;
                        if ($issueId <= 0 || isset($existingIssueSet[$issueId])) {
                            continue;
                        }
                        $source = $sourceByIssue[$issueId] ?? null;
                        if (! is_array($source)) {
                            continue;
                        }
                        $sourceQty = max(0, (int) ($source['base_qty'] ?? 0));
                        if ($sourceQty <= 0) {
                            continue;
                        }
                        $sourceCancel = max(0, min($sourceQty, (int) ($source['cancel_qty'] ?? 0)));
                        $db->table('bag_issue_item_code')->insert([
                            'bic_lg_idx' => $lgIdx,
                            'bic_bi2_idx' => $issueId,
                            'bic_bag_code' => $selectedBagCode,
                            'bic_issue_code' => sprintf('%02d-%06d-001', (int) date('y'), $issueId),
                            'bic_qty' => $sourceQty,
                            'bic_cancel_qty' => $sourceCancel,
                            'bic_state' => ($sourceCancel >= $sourceQty) ? 'cancelled' : 'normal',
                            'bic_regdate' => date('Y-m-d H:i:s'),
                        ]);
                    }

                    $data['codeRows'] = $db->table('bag_issue_item_code')
                        ->select('bic_idx, bic_bi2_idx, bic_issue_code, bic_qty, bic_cancel_qty')
                        ->where('bic_lg_idx', $lgIdx)
                        ->where('bic_bag_code', $selectedBagCode)
                        ->whereIn('bic_bi2_idx', $selectedIssueIds)
                        ->orderBy('bic_bi2_idx', 'ASC')
                        ->orderBy('bic_idx', 'ASC')
                        ->get()
                        ->getResultArray();
                }
            }

            $existingIssueIds = [];
            foreach (($data['codeRows'] ?? []) as $codeRow) {
                $existingIssueId = (int) ($codeRow['bic_bi2_idx'] ?? 0);
                if ($existingIssueId > 0) {
                    $existingIssueIds[$existingIssueId] = true;
                }
            }
            foreach ($sourceByIssue as $sourceIssueId => $sourceRow) {
                if (isset($existingIssueIds[$sourceIssueId])) {
                    continue;
                }
                $data['codeRows'][] = [
                    'bic_idx' => 0,
                    'bic_bi2_idx' => $sourceIssueId,
                    'bic_issue_code' => sprintf('%02d-%06d-001', (int) date('y'), $sourceIssueId),
                    'bic_qty' => (int) ($sourceRow['base_qty'] ?? 0),
                    'bic_cancel_qty' => (int) ($sourceRow['cancel_qty'] ?? 0),
                ];
            }

            if (($data['codeRows'] ?? []) !== []) {
                usort($data['codeRows'], static function (array $a, array $b): int {
                    $issueCmp = ((int) ($a['bic_bi2_idx'] ?? 0)) <=> ((int) ($b['bic_bi2_idx'] ?? 0));
                    if ($issueCmp !== 0) {
                        return $issueCmp;
                    }
                    return ((int) ($a['bic_idx'] ?? 0)) <=> ((int) ($b['bic_idx'] ?? 0));
                });
            }
        }

        return $this->render('불출 관리', 'bag/issue', $data);
    }

    public function issueCancelSave(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/issue/cancel'))->with('error', '지자체를 선택해 주세요.');
        }

        $db = \Config\Database::connect();
        $issueModel = model(BagIssueModel::class);
        $inventoryModel = model(BagInventoryModel::class);
        $hasItemCodeTable = $db->tableExists('bag_issue_item_code');

        $codeCancelQtyInput = $this->request->getPost('code_cancel_qty');
        $codeCancelQtyInput = is_array($codeCancelQtyInput) ? $codeCancelQtyInput : [];
        $codeCheckedInput = $this->request->getPost('code_cancel_check');
        $codeCheckedInput = is_array($codeCheckedInput) ? $codeCheckedInput : [];
        $issueCancelQtyInput = $this->request->getPost('issue_cancel_qty');
        $issueCancelQtyInput = is_array($issueCancelQtyInput) ? $issueCancelQtyInput : [];
        $issueCheckedInput = $this->request->getPost('issue_cancel_check');
        $issueCheckedInput = is_array($issueCheckedInput) ? $issueCheckedInput : [];

        $issueDeltaMap = [];
        $touchedIssueIds = [];

        $db->transStart();

        if ($hasItemCodeTable && $codeCancelQtyInput !== []) {
            $codeIds = array_values(array_unique(array_map('intval', array_keys($codeCancelQtyInput))));
            $codeIds = array_values(array_filter($codeIds, static fn ($v): bool => $v > 0));
            if ($codeIds !== []) {
                $rows = $db->table('bag_issue_item_code')
                    ->select('bic_idx, bic_bi2_idx, bic_qty, bic_cancel_qty')
                    ->where('bic_lg_idx', $lgIdx)
                    ->whereIn('bic_idx', $codeIds)
                    ->get()
                    ->getResultArray();

                foreach ($rows as $row) {
                    $bicIdx = (int) ($row['bic_idx'] ?? 0);
                    $bi2Idx = (int) ($row['bic_bi2_idx'] ?? 0);
                    $qty = (int) ($row['bic_qty'] ?? 0);
                    $oldCancel = (int) ($row['bic_cancel_qty'] ?? 0);
                    $isChecked = isset($codeCheckedInput[(string) $bicIdx]);
                    $inputCancel = (int) ($codeCancelQtyInput[(string) $bicIdx] ?? 0);
                    $newCancel = $isChecked ? $qty : max(0, min($qty, $inputCancel));
                    if ($newCancel === $oldCancel) {
                        continue;
                    }
                    $db->table('bag_issue_item_code')
                        ->where('bic_idx', $bicIdx)
                        ->update([
                            'bic_cancel_qty' => $newCancel,
                            'bic_state' => ($newCancel >= $qty) ? 'cancelled' : 'normal',
                        ]);

                    if (! isset($issueDeltaMap[$bi2Idx])) {
                        $issueDeltaMap[$bi2Idx] = 0;
                    }
                    $issueDeltaMap[$bi2Idx] += ($newCancel - $oldCancel);
                    $touchedIssueIds[$bi2Idx] = true;
                }
            }
        }

        $fallbackIssueIds = array_values(array_unique(array_map('intval', array_keys($issueCancelQtyInput))));
        $fallbackIssueIds = array_values(array_filter($fallbackIssueIds, static fn ($v): bool => $v > 0));
        if ($fallbackIssueIds !== []) {
            $issueRows = $issueModel
                ->where('bi2_lg_idx', $lgIdx)
                ->whereIn('bi2_idx', $fallbackIssueIds)
                ->findAll();
            foreach ($issueRows as $issueRow) {
                $bi2Idx = (int) ($issueRow->bi2_idx ?? 0);
                if ($bi2Idx <= 0 || isset($touchedIssueIds[$bi2Idx])) {
                    continue;
                }
                $baseQty = (int) ($issueRow->bi2_qty ?? 0);
                $isChecked = isset($issueCheckedInput[(string) $bi2Idx]);
                $inputCancel = (int) ($issueCancelQtyInput[(string) $bi2Idx] ?? 0);
                $newCancel = $isChecked ? $baseQty : max(0, min($baseQty, $inputCancel));
                if ($newCancel <= 0) {
                    continue;
                }
                $issueModel->update($bi2Idx, [
                    'bi2_qty' => $baseQty - $newCancel,
                    'bi2_status' => ($newCancel >= $baseQty) ? 'cancelled' : 'normal',
                ]);
                $inventoryModel->adjustQty(
                    $lgIdx,
                    (string) ($issueRow->bi2_bag_code ?? ''),
                    (string) ($issueRow->bi2_bag_name ?? ''),
                    $newCancel
                );
            }
        }

        if ($touchedIssueIds !== []) {
            $issueIds = array_keys($touchedIssueIds);
            $aggRows = $db->table('bag_issue_item_code')
                ->select('bic_bi2_idx, SUM(bic_qty) AS sum_qty, SUM(bic_cancel_qty) AS sum_cancel', false)
                ->whereIn('bic_bi2_idx', $issueIds)
                ->groupBy('bic_bi2_idx')
                ->get()
                ->getResultArray();
            $aggMap = [];
            foreach ($aggRows as $row) {
                $idx = (int) ($row['bic_bi2_idx'] ?? 0);
                if ($idx <= 0) {
                    continue;
                }
                $aggMap[$idx] = [
                    'sum_qty' => (int) ($row['sum_qty'] ?? 0),
                    'sum_cancel' => (int) ($row['sum_cancel'] ?? 0),
                ];
            }

            $issues = $issueModel->where('bi2_lg_idx', $lgIdx)->whereIn('bi2_idx', $issueIds)->findAll();
            foreach ($issues as $issue) {
                $bi2Idx = (int) ($issue->bi2_idx ?? 0);
                $sumQty = (int) ($aggMap[$bi2Idx]['sum_qty'] ?? (int) ($issue->bi2_qty ?? 0));
                $sumCancel = (int) ($aggMap[$bi2Idx]['sum_cancel'] ?? 0);
                $remain = max(0, $sumQty - $sumCancel);
                $issueModel->update($bi2Idx, [
                    'bi2_qty' => $remain,
                    'bi2_status' => ($remain <= 0 ? 'cancelled' : 'normal'),
                ]);

                $delta = (int) ($issueDeltaMap[$bi2Idx] ?? 0);
                if ($delta !== 0) {
                    $inventoryModel->adjustQty(
                        $lgIdx,
                        (string) ($issue->bi2_bag_code ?? ''),
                        (string) ($issue->bi2_bag_name ?? ''),
                        $delta
                    );
                }
            }
        }

        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->withInput()->with('error', '불출 취소 저장 중 오류가 발생했습니다.');
        }

        return redirect()->back()->with('success', '불출 취소 수량이 저장되었습니다.');
    }

    // ──────────────────────────────────────────────
    // 재고 관리
    // ──────────────────────────────────────────────
    public function inventory(): string
    {
        $lgIdx = $this->lgIdx();
        $baseDate = trim((string) ($this->request->getGet('base_date') ?? date('Y-m-d')));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $baseDate)) {
            $baseDate = date('Y-m-d');
        }
        $agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);
        $data = [
            'baseDate' => $baseDate,
            'agencyIdx' => $agencyIdx,
            'agencyOptions' => [],
            'rows' => [],
            'subtotals' => [],
            'grandTotals' => ['total' => 0, 'gugun' => 0, 'agency' => 0],
        ];

        if ($lgIdx) {
            $agencyModel = model(SalesAgencyModel::class);
            $data['agencyOptions'] = $agencyModel
                ->where('sa_lg_idx', $lgIdx)
                ->orderForDisplay()
                ->findAll();

            $report = $this->buildInventoryStatusData($lgIdx, $baseDate, $agencyIdx);
            $data = array_merge($data, $report);
        }

        return $this->render('재고 현황', 'bag/inventory', $data);
    }

    public function inventoryExport(): ResponseInterface|RedirectResponse
    {
        helper(['admin', 'export']);
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $baseDate = trim((string) ($this->request->getGet('base_date') ?? date('Y-m-d')));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $baseDate)) {
            $baseDate = date('Y-m-d');
        }
        $agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);

        $report = $this->buildInventoryStatusData($lgIdx, $baseDate, $agencyIdx);
        $rows = [];
        foreach (($report['rows'] ?? []) as $row) {
            $rows[] = [
                (string) ($row['group'] ?? ''),
                (string) ($row['name'] ?? ''),
                (int) ($row['total_qty'] ?? 0),
                (int) ($row['gugun_qty'] ?? 0),
                (int) ($row['agency_qty'] ?? 0),
            ];
        }
        foreach (($report['subtotals'] ?? []) as $subtotal) {
            $rows[] = [
                (string) ($subtotal['group'] ?? ''),
                '소계',
                (int) ($subtotal['total_qty'] ?? 0),
                (int) ($subtotal['gugun_qty'] ?? 0),
                (int) ($subtotal['agency_qty'] ?? 0),
            ];
        }
        $rows[] = [
            '',
            '합계',
            (int) ($report['grandTotals']['total'] ?? 0),
            (int) ($report['grandTotals']['gugun'] ?? 0),
            (int) ($report['grandTotals']['agency'] ?? 0),
        ];

        export_xlsx(
            '재고현황_' . str_replace('-', '', $baseDate) . '.xlsx',
            '재고현황',
            ['품목구분', '봉투/스티커 종류', '계', '시군구 재고', '대행소 재고'],
            $rows
        );
    }

    /**
     * @return array{
     *   rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
     *   subtotals: list<array{group:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
     *   grandTotals: array{total:int,gugun:int,agency:int}
     * }
     */
    private function buildInventoryStatusData(int $lgIdx, string $baseDate, int $agencyIdx): array
    {
        $builder = model(BagInventoryModel::class)
            ->where('bi_lg_idx', $lgIdx)
            ->where('bi_updated_at <=', $baseDate . ' 23:59:59')
            ->orderBy('bi_bag_code', 'ASC');

        // 대행소 재고 연계 테이블이 아직 없어 agency 필터는 조회조건 표시용으로만 유지한다.
        if ($agencyIdx > 0) {
            // no-op
        }

        $list = $builder->findAll();
        $rows = [];
        $subtotalMap = [];
        $groupOrder = [];
        $grand = ['total' => 0, 'gugun' => 0, 'agency' => 0];

        foreach ($list as $row) {
            $bagName = trim((string) ($row->bi_bag_name ?? ''));
            $bagCode = trim((string) ($row->bi_bag_code ?? ''));
            $group = $this->inventoryGroupLabel($bagName, $bagCode);
            if (! isset($groupOrder[$group])) {
                $groupOrder[$group] = count($groupOrder);
            }

            $gugunQty = max(0, (int) ($row->bi_qty ?? 0));
            $agencyQty = 0;
            $totalQty = $gugunQty + $agencyQty;

            $rows[] = [
                'group' => $group,
                'name' => $bagName !== '' ? $bagName : $bagCode,
                'total_qty' => $totalQty,
                'gugun_qty' => $gugunQty,
                'agency_qty' => $agencyQty,
                '_sort' => $groupOrder[$group],
            ];

            if (! isset($subtotalMap[$group])) {
                $subtotalMap[$group] = ['group' => $group, 'total_qty' => 0, 'gugun_qty' => 0, 'agency_qty' => 0];
            }
            $subtotalMap[$group]['total_qty'] += $totalQty;
            $subtotalMap[$group]['gugun_qty'] += $gugunQty;
            $subtotalMap[$group]['agency_qty'] += $agencyQty;

            $grand['total'] += $totalQty;
            $grand['gugun'] += $gugunQty;
            $grand['agency'] += $agencyQty;
        }

        usort($rows, static function (array $a, array $b): int {
            $g = ((int) ($a['_sort'] ?? 0)) <=> ((int) ($b['_sort'] ?? 0));
            if ($g !== 0) {
                return $g;
            }
            return strnatcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
        });
        foreach ($rows as &$row) {
            unset($row['_sort']);
        }
        unset($row);

        $subtotals = array_values($subtotalMap);
        usort($subtotals, static function (array $a, array $b) use ($groupOrder): int {
            return ((int) ($groupOrder[$a['group']] ?? 0)) <=> ((int) ($groupOrder[$b['group']] ?? 0));
        });

        return [
            'rows' => $rows,
            'subtotals' => $subtotals,
            'grandTotals' => $grand,
        ];
    }

    private function inventoryGroupLabel(string $bagName, string $bagCode): string
    {
        $name = trim($bagName);
        $code = trim($bagCode);
        $source = $name !== '' ? $name : $code;

        if (mb_strpos($source, '스티커') !== false) {
            if (mb_strpos($source, '음식물') !== false) {
                return '음식물 스티커';
            }
            if (mb_strpos($source, '폐기물') !== false) {
                return '대형폐기물 스티커';
            }

            return '기타 스티커';
        }

        if (mb_strpos($source, '재사용') !== false) {
            return '재사용';
        }
        if (mb_strpos($source, '공공') !== false || mb_strpos($source, '공동주택') !== false) {
            return '공공용';
        }
        if (mb_strpos($source, '음식물') !== false) {
            return '음식물 봉투';
        }

        return '일반용';
    }

    public function inspectionSelect(): string|RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $this->ensureInventoryInspectionTables();
        $this->ensureInspectionPackSnapshotTable();
        $today = date('Y-m-d');
        $startDate = trim((string) ($this->request->getGet('start_date') ?? date('Y-m-01')));
        $endDate = trim((string) ($this->request->getGet('end_date') ?? $today));
        $workDate = trim((string) ($this->request->getGet('work_date') ?? $today));
        $itemCode = trim((string) ($this->request->getGet('item_code') ?? ''));
        $selectedInspectionId = (int) ($this->request->getGet('bis_id') ?? 0);
        $viewType = trim((string) ($this->request->getGet('view_type') ?? 'box'));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
            $startDate = date('Y-m-01');
        }
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
            $endDate = $today;
        }
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
            $workDate = $today;
        }
        if (! in_array($viewType, ['box', 'pack'], true)) {
            $viewType = 'box';
        }

        $inventoryRows = model(BagInventoryModel::class)
            ->where('bi_lg_idx', $lgIdx)
            ->orderBy('bi_bag_code', 'ASC')
            ->findAll();

        $db = \Config\Database::connect();
        $barcodeRows = $db->table('bag_receiving_pack_code')
            ->select('brpc_bag_code')
            ->distinct()
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_bag_code !=', '')
            ->get()
            ->getResultArray();
        $barcodeSet = [];
        foreach ($barcodeRows as $row) {
            $code = trim((string) ($row['brpc_bag_code'] ?? ''));
            if ($code !== '') {
                $barcodeSet[$code] = true;
            }
        }

        $popupItems = [];
        foreach ($inventoryRows as $inv) {
            $code = trim((string) ($inv->bi_bag_code ?? ''));
            if ($code === '') {
                continue;
            }
            $name = trim((string) ($inv->bi_bag_name ?? $code));
            $qty = (int) ($inv->bi_qty ?? 0);
            $hasBarcode = isset($barcodeSet[$code]);
            $popupItems[] = [
                'bag_code' => $code,
                'bag_name' => $name,
                'qty' => $qty,
                'has_barcode' => $hasBarcode,
            ];
        }

        if ($selectedInspectionId <= 0) {
            $latestInspection = $db->table('bag_inventory_inspection')
                ->select("bis_idx, (CASE bis_status WHEN 'confirmed' THEN 3 WHEN 'counting' THEN 2 WHEN 'selected' THEN 1 ELSE 0 END) AS status_rank", false)
                ->where('bis_lg_idx', $lgIdx)
                ->where('bis_work_date >=', $startDate)
                ->where('bis_work_date <=', $endDate)
                ->orderBy('status_rank', 'DESC')
                ->orderBy('bis_work_date', 'DESC')
                ->orderBy('bis_idx', 'DESC')
                ->get()
                ->getRowArray();
            $selectedInspectionId = (int) ($latestInspection['bis_idx'] ?? 0);
        }

        $inspectionRuns = $db->table('bag_inventory_inspection')
            ->select('bis_idx, bis_work_date, bis_status')
            ->where('bis_lg_idx', $lgIdx)
            ->where('bis_work_date >=', $startDate)
            ->where('bis_work_date <=', $endDate)
            ->orderBy('bis_work_date', 'DESC')
            ->orderBy('bis_idx', 'DESC')
            ->get()
            ->getResultArray();

        $overviewBuilder = $db->table('bag_inventory_inspection_item i')
            ->select('i.bisi_idx, i.bisi_bis_idx, i.bisi_bag_code, i.bisi_bag_name, i.bisi_system_qty, h.bis_work_date, h.bis_status')
            ->join('bag_inventory_inspection h', 'h.bis_idx = i.bisi_bis_idx', 'inner')
            ->where('h.bis_lg_idx', $lgIdx)
            ->where('h.bis_work_date >=', $startDate)
            ->where('h.bis_work_date <=', $endDate);
        if ($selectedInspectionId > 0) {
            $overviewBuilder->where('i.bisi_bis_idx', $selectedInspectionId);
        }
        if ($itemCode !== '') {
            $overviewBuilder->where('i.bisi_bag_code', $itemCode);
        }
        $overviewRows = $overviewBuilder
            ->orderBy('h.bis_work_date', 'ASC')
            ->orderBy('i.bisi_bag_code', 'ASC')
            ->orderBy('i.bisi_idx', 'ASC')
            ->get()
            ->getResultArray();
        $overviewRows = $this->expandInspectionRowsByBox($db, $lgIdx, $overviewRows, true);

        $selectedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
        if ($selectedInspectionItemId <= 0 && $overviewRows !== []) {
            $selectedInspectionItemId = (int) ($overviewRows[0]['bisi_idx'] ?? 0);
        }
        $selectedInspectionItem = null;
        foreach ($overviewRows as $row) {
            if ((int) ($row['bisi_idx'] ?? 0) === $selectedInspectionItemId) {
                $selectedInspectionItem = $row;
                $selectedInspectionId = (int) ($row['bisi_bis_idx'] ?? 0);
                break;
            }
        }

        $items = [];
        foreach ($overviewRows as $row) {
            $code = trim((string) ($row['bisi_bag_code'] ?? ''));
            if ($code === '' || isset($items[$code])) {
                continue;
            }
            $items[$code] = [
                'bag_code' => $code,
                'bag_name' => trim((string) ($row['bisi_bag_name'] ?? $code)),
            ];
        }
        $items = array_values($items);

        $boxRows = [];
        $sheetRows = [];
        $selectedBoxCode = trim((string) ($this->request->getGet('sel_box_code') ?? ''));
        $selectedPackCode = trim((string) ($this->request->getGet('sel_pack_code') ?? ''));
        if (is_array($selectedInspectionItem)) {
            $this->ensureInspectionPackSnapshotForItem($lgIdx, $selectedInspectionItemId);
            $bagCode = trim((string) ($selectedInspectionItem['bisi_bag_code'] ?? ''));
            if ($bagCode !== '') {
                $boxRows = $db->table('bag_inventory_inspection_pack_snapshot')
                    ->select('bisp_idx, bisp_box_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty')
                    ->where('bisp_lg_idx', $lgIdx)
                    ->where('bisp_bisi_idx', $selectedInspectionItemId)
                    ->where('bisp_bag_code', $bagCode)
                    ->orderBy('bisp_sheet_qty', 'DESC')
                    ->orderBy('bisp_idx', 'ASC')
                    ->get()
                    ->getResultArray();
            }
            if ($selectedBoxCode === '' && $boxRows !== []) {
                $selectedBoxCode = (string) ($boxRows[0]['bisp_box_code'] ?? '');
            }
            if ($selectedPackCode === '' && $boxRows !== []) {
                $selectedPackCode = (string) ($boxRows[0]['bisp_pack_code'] ?? '');
            }
            foreach ($boxRows as $boxRow) {
                $boxCode = (string) ($boxRow['bisp_box_code'] ?? '');
                $packCode = (string) ($boxRow['bisp_pack_code'] ?? '');
                if ($boxCode === '' || $packCode === '') {
                    continue;
                }
                if ($boxCode !== $selectedBoxCode || $packCode !== $selectedPackCode) {
                    continue;
                }
                $startCode = (string) ($boxRow['bisp_sheet_start_code'] ?? '');
                $endCode = (string) ($boxRow['bisp_sheet_end_code'] ?? '');
                $sheetRows = [[
                    'no' => 1,
                    'biss_sheet_code' => $startCode . ' ~ ' . $endCode,
                    'biss_system_qty' => max(0, (int) ($boxRow['bisp_sheet_qty'] ?? 0)),
                ]];
                break;
            }
        }

        return $this->render('실사 선별 조회', 'bag/inventory_inspection_select_overview', [
            'startDate' => $startDate,
            'endDate' => $endDate,
            'workDate' => $workDate,
            'itemCode' => $itemCode,
            'viewType' => $viewType,
            'inspectionRuns' => $inspectionRuns,
            'items' => $items,
            'selectedInspectionId' => $selectedInspectionId,
            'selectedInspectionItemId' => $selectedInspectionItemId,
            'overviewRows' => $overviewRows,
            'boxRows' => $boxRows,
            'sheetRows' => $sheetRows,
            'selectedBoxCode' => $selectedBoxCode,
            'selectedPackCode' => $selectedPackCode,
            'popupItems' => $popupItems,
        ]);
    }

    public function inspectionWork(): string|RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $this->ensureInventoryInspectionTables();
        $this->ensureInspectionPackSnapshotTable();
        $this->ensureInspectionSheetSnapshotTable();
        $today = date('Y-m-d');
        $startDate = trim((string) ($this->request->getGet('start_date') ?? date('Y-m-01')));
        $endDate = trim((string) ($this->request->getGet('end_date') ?? $today));
        $workDate = trim((string) ($this->request->getGet('work_date') ?? $today));
        $itemCode = trim((string) ($this->request->getGet('item_code') ?? ''));
        $selectedInspectionId = (int) ($this->request->getGet('bis_id') ?? 0);
        $viewType = trim((string) ($this->request->getGet('view_type') ?? 'box'));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
            $startDate = date('Y-m-01');
        }
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
            $endDate = $today;
        }
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
            $workDate = $today;
        }
        if (! in_array($viewType, ['box', 'pack'], true)) {
            $viewType = 'box';
        }

        $inventoryRows = model(BagInventoryModel::class)
            ->where('bi_lg_idx', $lgIdx)
            ->orderBy('bi_bag_code', 'ASC')
            ->findAll();

        $db = \Config\Database::connect();
        $barcodeRows = $db->table('bag_receiving_pack_code')
            ->select('brpc_bag_code')
            ->distinct()
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_bag_code !=', '')
            ->get()
            ->getResultArray();
        $barcodeSet = [];
        foreach ($barcodeRows as $row) {
            $code = trim((string) ($row['brpc_bag_code'] ?? ''));
            if ($code !== '') {
                $barcodeSet[$code] = true;
            }
        }

        $popupItems = [];
        foreach ($inventoryRows as $inv) {
            $code = trim((string) ($inv->bi_bag_code ?? ''));
            if ($code === '') {
                continue;
            }
            $popupItems[] = [
                'bag_code' => $code,
                'bag_name' => trim((string) ($inv->bi_bag_name ?? $code)),
                'qty' => (int) ($inv->bi_qty ?? 0),
                'has_barcode' => isset($barcodeSet[$code]),
            ];
        }

        if ($selectedInspectionId <= 0) {
            $latestInspection = $db->table('bag_inventory_inspection')
                ->select("bis_idx, (CASE bis_status WHEN 'confirmed' THEN 3 WHEN 'counting' THEN 2 WHEN 'selected' THEN 1 ELSE 0 END) AS status_rank", false)
                ->where('bis_lg_idx', $lgIdx)
                ->where('bis_work_date >=', $startDate)
                ->where('bis_work_date <=', $endDate)
                ->orderBy('status_rank', 'DESC')
                ->orderBy('bis_work_date', 'DESC')
                ->orderBy('bis_idx', 'DESC')
                ->get()
                ->getRowArray();
            $selectedInspectionId = (int) ($latestInspection['bis_idx'] ?? 0);
        }

        $inspectionRuns = $db->table('bag_inventory_inspection')
            ->select('bis_idx, bis_work_date, bis_status')
            ->where('bis_lg_idx', $lgIdx)
            ->where('bis_work_date >=', $startDate)
            ->where('bis_work_date <=', $endDate)
            ->orderBy('bis_work_date', 'DESC')
            ->orderBy('bis_idx', 'DESC')
            ->get()
            ->getResultArray();

        $requestedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
        if ($requestedInspectionItemId > 0) {
            $this->ensureInspectionPackSnapshotForItem($lgIdx, $requestedInspectionItemId);
        }

        $overviewBuilder = $db->table('bag_inventory_inspection_item i')
            ->select('i.bisi_idx, i.bisi_bis_idx, i.bisi_bag_code, i.bisi_bag_name, i.bisi_system_qty, i.bisi_actual_qty, i.bisi_diff_qty, i.bisi_apply_yn, h.bis_work_date, h.bis_status')
            ->join('bag_inventory_inspection h', 'h.bis_idx = i.bisi_bis_idx', 'inner')
            ->where('h.bis_lg_idx', $lgIdx)
            ->where('h.bis_work_date >=', $startDate)
            ->where('h.bis_work_date <=', $endDate);
        if ($selectedInspectionId > 0) {
            $overviewBuilder->where('i.bisi_bis_idx', $selectedInspectionId);
        }
        if ($itemCode !== '') {
            $overviewBuilder->where('i.bisi_bag_code', $itemCode);
        }
        $overviewRows = $overviewBuilder
            ->orderBy('h.bis_work_date', 'ASC')
            ->orderBy('i.bisi_bag_code', 'ASC')
            ->orderBy('i.bisi_idx', 'ASC')
            ->get()
            ->getResultArray();
        $overviewRows = $this->expandInspectionRowsByBox($db, $lgIdx, $overviewRows, true);

        $selectedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
        if ($selectedInspectionItemId <= 0 && $overviewRows !== []) {
            $selectedInspectionItemId = (int) ($overviewRows[0]['bisi_idx'] ?? 0);
        }
        $selectedInspectionItem = null;
        foreach ($overviewRows as $row) {
            if ((int) ($row['bisi_idx'] ?? 0) === $selectedInspectionItemId) {
                $selectedInspectionItem = $row;
                $selectedInspectionId = (int) ($row['bisi_bis_idx'] ?? 0);
                break;
            }
        }

        $items = [];
        foreach ($overviewRows as $row) {
            $code = trim((string) ($row['bisi_bag_code'] ?? ''));
            if ($code === '' || isset($items[$code])) {
                continue;
            }
            $items[$code] = [
                'bag_code' => $code,
                'bag_name' => trim((string) ($row['bisi_bag_name'] ?? $code)),
                'qty' => (int) ($row['bisi_system_qty'] ?? 0),
                'has_barcode' => true,
            ];
        }
        $items = array_values($items);

        $boxRows = [];
        $sheetRows = [];
        $selectedBoxCode = trim((string) ($this->request->getGet('sel_box_code') ?? ''));
        $selectedPackCode = trim((string) ($this->request->getGet('sel_pack_code') ?? ''));
        if (is_array($selectedInspectionItem)) {
            $this->ensureInspectionPackSnapshotForItem($lgIdx, $selectedInspectionItemId);
            $bagCode = trim((string) ($selectedInspectionItem['bisi_bag_code'] ?? ''));
            if ($bagCode !== '') {
                $boxRowsAll = $db->table('bag_inventory_inspection_pack_snapshot')
                    ->select('bisp_idx, bisp_box_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty, bisp_actual_qty, bisp_diff_qty')
                    ->where('bisp_lg_idx', $lgIdx)
                    ->where('bisp_bisi_idx', $selectedInspectionItemId)
                    ->where('bisp_bag_code', $bagCode)
                    ->orderBy('bisp_sheet_qty', 'DESC')
                    ->orderBy('bisp_idx', 'ASC')
                    ->get()
                    ->getResultArray();
                foreach ($boxRowsAll as &$boxRow) {
                    $systemQty = max(0, (int) ($boxRow['bisp_sheet_qty'] ?? 0));
                    $actualRaw = $boxRow['bisp_actual_qty'] ?? null;
                    $actualQty = $actualRaw === null ? $systemQty : max(0, (int) $actualRaw);
                    // 화면 초기 표시값은 포장량/재고/실사재고를 동일하게 맞춘다.
                    $displayQty = $actualQty;
                    $boxRow['bisp_sheet_qty'] = $displayQty;
                    $boxRow['bisp_actual_qty'] = $displayQty;
                    $boxRow['bisp_diff_qty'] = 0;
                }
                unset($boxRow);
                if ($selectedBoxCode !== '') {
                    $boxRows = array_values(array_filter(
                        $boxRowsAll,
                        static fn (array $row): bool => trim((string) ($row['bisp_box_code'] ?? '')) === $selectedBoxCode
                    ));
                } else {
                    $boxRows = $boxRowsAll;
                }
            }
            if ($selectedBoxCode === '' && $boxRows !== []) {
                $selectedBoxCode = (string) ($boxRows[0]['bisp_box_code'] ?? '');
                $boxRows = array_values(array_filter(
                    $boxRows,
                    static fn (array $row): bool => trim((string) ($row['bisp_box_code'] ?? '')) === $selectedBoxCode
                ));
            }
            if ($selectedPackCode === '' && $boxRows !== []) {
                $selectedPackCode = (string) ($boxRows[0]['bisp_pack_code'] ?? '');
            }
            foreach ($boxRows as $boxRow) {
                $boxCode = (string) ($boxRow['bisp_box_code'] ?? '');
                $packCode = (string) ($boxRow['bisp_pack_code'] ?? '');
                if ($boxCode === '' || $packCode === '') {
                    continue;
                }
                if ($boxCode !== $selectedBoxCode || $packCode !== $selectedPackCode) {
                    continue;
                }
                $this->ensureInspectionSheetSnapshotForPack(
                    $lgIdx,
                    $selectedInspectionItemId,
                    $packCode,
                    (string) ($boxRow['bisp_sheet_start_code'] ?? ''),
                    (string) ($boxRow['bisp_sheet_end_code'] ?? '')
                );
                $sheetRows = $db->table('bag_inventory_inspection_sheet_snapshot')
                    ->select('biss_idx, biss_sheet_code, biss_system_qty, biss_actual_qty, biss_diff_qty, biss_checked_yn')
                    ->where('biss_lg_idx', $lgIdx)
                    ->where('biss_bisi_idx', $selectedInspectionItemId)
                    ->where('biss_pack_code', $packCode)
                    ->orderBy('biss_sheet_code', 'ASC')
                    ->get()
                    ->getResultArray();
                $n = 1;
                foreach ($sheetRows as &$sr) {
                    $sr['no'] = $n++;
                }
                unset($sr);
                break;
            }
        }

        return $this->render('실사 선별 관리', 'bag/inventory_inspection_select', [
            'startDate' => $startDate,
            'endDate' => $endDate,
            'workDate' => $workDate,
            'itemCode' => $itemCode,
            'viewType' => $viewType,
            'inspectionRuns' => $inspectionRuns,
            'items' => $items,
            'popupItems' => $popupItems,
            'overviewRows' => $overviewRows,
            'selectedInspectionItemId' => $selectedInspectionItemId,
            'selectedInspectionId' => $selectedInspectionId,
            'boxRows' => $boxRows,
            'selectedBoxCode' => $selectedBoxCode,
            'selectedPackCode' => $selectedPackCode,
            'sheetRows' => $sheetRows,
        ]);
    }

    /**
     * @return list<array{no:int,sheet_code:string,qty:int}>
     */
    private function expandInspectionRowsByBox(\CodeIgniter\Database\BaseConnection $db, int $lgIdx, array $overviewRows, bool $includeActual): array
    {
        if ($overviewRows === []) {
            return [];
        }
        $itemIds = array_values(array_filter(array_map(
            static fn (array $r): int => (int) ($r['bisi_idx'] ?? 0),
            $overviewRows
        )));
        if ($itemIds === []) {
            return $overviewRows;
        }

        $boxAggRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_bisi_idx, bisp_box_code, SUM(bisp_sheet_qty) AS sum_system, SUM(COALESCE(bisp_actual_qty,0)) AS sum_actual, SUM(COALESCE(bisp_diff_qty,0)) AS sum_diff', false)
            ->where('bisp_lg_idx', $lgIdx)
            ->whereIn('bisp_bisi_idx', $itemIds)
            ->groupBy('bisp_bisi_idx, bisp_box_code')
            ->orderBy('bisp_bisi_idx', 'ASC')
            ->orderBy('bisp_box_code', 'ASC')
            ->get()
            ->getResultArray();
        $boxAggMap = [];
        foreach ($boxAggRows as $bRow) {
            $id = (int) ($bRow['bisp_bisi_idx'] ?? 0);
            if ($id <= 0) {
                continue;
            }
            $boxAggMap[$id][] = $bRow;
        }

        $expandedRows = [];
        foreach ($overviewRows as $row) {
            $itemId = (int) ($row['bisi_idx'] ?? 0);
            $group = $boxAggMap[$itemId] ?? [];
            if ($group === []) {
                $row['box_code'] = '';
                $expandedRows[] = $row;
                continue;
            }
            foreach ($group as $g) {
                $expanded = $row;
                $expanded['box_code'] = trim((string) ($g['bisp_box_code'] ?? ''));
                $expanded['bisi_total_system_qty'] = (int) ($row['bisi_system_qty'] ?? 0);
                $expanded['bisi_system_qty'] = (int) ($g['sum_system'] ?? 0);
                if ($includeActual) {
                    $expanded['bisi_actual_qty'] = (int) ($g['sum_actual'] ?? 0);
                    $expanded['bisi_diff_qty'] = (int) ($g['sum_diff'] ?? 0);
                }
                $expandedRows[] = $expanded;
            }
        }

        return $expandedRows;
    }

    private function expandInspectionRowsByPack(\CodeIgniter\Database\BaseConnection $db, int $lgIdx, array $overviewRows, bool $includeActual): array
    {
        if ($overviewRows === []) {
            return [];
        }
        $itemIds = array_values(array_filter(array_map(
            static fn (array $r): int => (int) ($r['bisi_idx'] ?? 0),
            $overviewRows
        )));
        if ($itemIds === []) {
            return $overviewRows;
        }

        $packRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_bisi_idx, bisp_idx, bisp_box_code, bisp_sheet_qty, COALESCE(bisp_actual_qty,0) AS bisp_actual_qty, COALESCE(bisp_diff_qty,0) AS bisp_diff_qty', false)
            ->where('bisp_lg_idx', $lgIdx)
            ->whereIn('bisp_bisi_idx', $itemIds)
            ->orderBy('bisp_bisi_idx', 'ASC')
            ->orderBy('bisp_box_code', 'ASC')
            ->orderBy('bisp_idx', 'ASC')
            ->get()
            ->getResultArray();
        $packMap = [];
        foreach ($packRows as $pRow) {
            $id = (int) ($pRow['bisp_bisi_idx'] ?? 0);
            if ($id <= 0) {
                continue;
            }
            $packMap[$id][] = $pRow;
        }

        $expandedRows = [];
        foreach ($overviewRows as $row) {
            $itemId = (int) ($row['bisi_idx'] ?? 0);
            $group = $packMap[$itemId] ?? [];
            if ($group === []) {
                $row['box_code'] = '';
                $expandedRows[] = $row;
                continue;
            }
            foreach ($group as $g) {
                $expanded = $row;
                $expanded['box_code'] = trim((string) ($g['bisp_box_code'] ?? ''));
                $expanded['bisi_total_system_qty'] = (int) ($row['bisi_system_qty'] ?? 0);
                $expanded['bisi_system_qty'] = (int) ($g['bisp_sheet_qty'] ?? 0);
                if ($includeActual) {
                    $expanded['bisi_actual_qty'] = (int) ($g['bisp_actual_qty'] ?? 0);
                    $expanded['bisi_diff_qty'] = (int) ($g['bisp_diff_qty'] ?? 0);
                }
                $expandedRows[] = $expanded;
            }
        }

        return $expandedRows;
    }

    /**
     * @return list<array{no:int,sheet_code:string,qty:int}>
     */
    private function expandSheetCodes(string $startCode, string $endCode): array
    {
        $startCode = trim($startCode);
        $endCode = trim($endCode);
        if ($startCode === '' || $endCode === '') {
            return [];
        }

        if (preg_match('/^(.*?)(\d+)$/', $startCode, $sm) !== 1 || preg_match('/^(.*?)(\d+)$/', $endCode, $em) !== 1) {
            return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
        }

        $startPrefix = (string) ($sm[1] ?? '');
        $endPrefix = (string) ($em[1] ?? '');
        $startNumRaw = (string) ($sm[2] ?? '');
        $endNumRaw = (string) ($em[2] ?? '');
        if ($startPrefix !== $endPrefix) {
            return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
        }

        $startNum = (int) $startNumRaw;
        $endNum = (int) $endNumRaw;
        if ($startNum <= 0 || $endNum < $startNum) {
            return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
        }

        $width = max(strlen($startNumRaw), strlen($endNumRaw));
        $rows = [];
        $no = 1;
        for ($n = $startNum; $n <= $endNum; $n++) {
            $rows[] = [
                'no' => $no++,
                'sheet_code' => $startPrefix . str_pad((string) $n, $width, '0', STR_PAD_LEFT),
                'qty' => 1,
            ];
            if ($no > 10000) {
                break;
            }
        }

        return $rows;
    }

    public function inspectionRun(): RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $this->ensureInventoryInspectionTables();
        $this->ensureInspectionPackSnapshotTable();
        $this->ensureInspectionSheetSnapshotTable();
        $workDate = trim((string) ($this->request->getPost('work_date') ?? ''));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
            return redirect()->back()->withInput()->with('error', '작업일자를 확인해 주세요.');
        }

        $selectedCodes = $this->request->getPost('bag_codes');
        $selectedCodes = is_array($selectedCodes) ? array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes))) : [];
        $selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== ''));
        if ($selectedCodes === []) {
            return redirect()->back()->withInput()->with('error', '실사 대상 품목을 선택해 주세요.');
        }

        $db = \Config\Database::connect();
        $barcodeRows = $db->table('bag_receiving_pack_code')
            ->select('brpc_bag_code')
            ->distinct()
            ->where('brpc_lg_idx', $lgIdx)
            ->whereIn('brpc_bag_code', $selectedCodes)
            ->get()
            ->getResultArray();
        $barcodeSet = [];
        foreach ($barcodeRows as $row) {
            $code = trim((string) ($row['brpc_bag_code'] ?? ''));
            if ($code !== '') {
                $barcodeSet[$code] = true;
            }
        }
        $effectiveCodes = array_values(array_filter($selectedCodes, static fn ($code): bool => isset($barcodeSet[$code])));
        if ($effectiveCodes === []) {
            return redirect()->back()->withInput()->with('error', '바코드가 있는 품목만 실사 대상으로 선택할 수 있습니다.');
        }
        foreach ($effectiveCodes as $code) {
            $this->ensureReceivingPackCodesForBag($lgIdx, $code);
        }

        $inventoryRows = $db->table('bag_inventory')
            ->select('bi_bag_code, bi_bag_name, bi_qty')
            ->where('bi_lg_idx', $lgIdx)
            ->whereIn('bi_bag_code', $effectiveCodes)
            ->orderBy('bi_bag_code', 'ASC')
            ->get()
            ->getResultArray();
        if ($inventoryRows === []) {
            return redirect()->back()->withInput()->with('error', '선택한 품목의 재고 데이터가 없습니다.');
        }

        $db->transStart();
        $firstInspectionItemId = 0;
        $db->table('bag_inventory_inspection')->insert([
            'bis_lg_idx' => $lgIdx,
            'bis_work_date' => $workDate,
            'bis_status' => 'selected',
            'bis_reg_mb_idx' => (int) (session()->get('mb_idx') ?? 0),
            'bis_regdate' => date('Y-m-d H:i:s'),
            'bis_moddate' => null,
        ]);
        $inspectionId = (int) $db->insertID();
        foreach ($inventoryRows as $row) {
            $code = trim((string) ($row['bi_bag_code'] ?? ''));
            if ($code === '' || ! isset($barcodeSet[$code])) {
                continue;
            }
            $systemQty = (int) ($row['bi_qty'] ?? 0);
            $db->table('bag_inventory_inspection_item')->insert([
                'bisi_bis_idx' => $inspectionId,
                'bisi_bag_code' => $code,
                'bisi_bag_name' => trim((string) ($row['bi_bag_name'] ?? $code)),
                'bisi_system_qty' => $systemQty,
                'bisi_actual_qty' => null,
                'bisi_diff_qty' => 0,
                'bisi_has_barcode' => 'Y',
                'bisi_apply_yn' => 'N',
            ]);
            $inspectionItemId = (int) $db->insertID();
            if ($firstInspectionItemId <= 0 && $inspectionItemId > 0) {
                $firstInspectionItemId = $inspectionItemId;
            }
        }
        $db->transComplete();
        if (! $db->transStatus() || $inspectionId <= 0) {
            return redirect()->back()->withInput()->with('error', '전산 선별 처리 중 오류가 발생했습니다.');
        }

        $query = http_build_query([
            'start_date' => $workDate,
            'end_date' => $workDate,
            'bis_id' => $inspectionId,
            'sel_item_id' => $firstInspectionItemId,
        ]);

        return redirect()->to(site_url('bag/inventory/inspection-work?' . $query))
            ->with('success', '전산 선별 처리가 완료되었습니다.');
    }

    public function inspectionSelectSave(): RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $this->ensureInventoryInspectionTables();
        $this->ensureInspectionPackSnapshotTable();
        $this->ensureInspectionSheetSnapshotTable();

        $inspectionItemId = (int) ($this->request->getPost('bisi_idx') ?? 0);
        if ($inspectionItemId <= 0) {
            return redirect()->back()->with('error', '실사 대상 품목이 올바르지 않습니다.');
        }
        $returnQuery = $this->inspectionReturnQueryFromPost($inspectionItemId);

        $db = \Config\Database::connect();
        $item = $db->table('bag_inventory_inspection_item')
            ->where('bisi_idx', $inspectionItemId)
            ->get()
            ->getRowArray();
        if (! is_array($item) || (int) ($item['bisi_idx'] ?? 0) <= 0) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 대상 품목을 찾을 수 없습니다.');
        }
        $requestInspectionId = (int) ($this->request->getPost('bis_id') ?? 0);
        $itemInspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
        if ($requestInspectionId > 0 && $requestInspectionId !== $itemInspectionId) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '선택한 실사 작업과 품목 정보가 일치하지 않습니다.');
        }
        $header = $db->table('bag_inventory_inspection')
            ->select('bis_idx')
            ->where('bis_idx', $itemInspectionId)
            ->where('bis_lg_idx', $lgIdx)
            ->get()
            ->getRowArray();
        if (! is_array($header) || (int) ($header['bis_idx'] ?? 0) <= 0) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 작업 정보가 올바르지 않습니다.');
        }

        $actualInput = $this->request->getPost('pack_actual_qty');
        $actualInput = is_array($actualInput) ? $actualInput : [];
        $actualJson = trim((string) ($this->request->getPost('pack_actual_json') ?? ''));
        $actualFromJson = false;
        if ($actualJson !== '') {
            $decoded = json_decode($actualJson, true);
            if (is_array($decoded)) {
                $actualInput = [];
                foreach ($decoded as $k => $v) {
                    $key = trim((string) $k);
                    if ($key === '' || ! ctype_digit($key)) {
                        continue;
                    }
                    $actualInput[$key] = max(0, (int) $v);
                }
                $actualFromJson = true;
            }
        }
        if ($actualFromJson && $actualInput === []) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '저장할 실사 수량(JSON)이 비어 있습니다. 다시 시도해 주세요.');
        }
        if (! $actualFromJson && $actualInput === []) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '저장할 실사 수량이 없습니다. 수량을 변경한 뒤 다시 저장해 주세요.');
        }
        $snapshotRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_idx, bisp_bag_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty, bisp_actual_qty')
            ->where('bisp_lg_idx', $lgIdx)
            ->where('bisp_bisi_idx', $inspectionItemId)
            ->orderBy('bisp_idx', 'ASC')
            ->get()
            ->getResultArray();
        if ($snapshotRows === []) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 팩 스냅샷이 없습니다.');
        }

        $db->transStart();
        $sumActual = 0;
        $packUpdates = [];
        $changedPackQtyMap = [];
        $bagCodeForSync = trim((string) ($item['bisi_bag_code'] ?? ''));
        $capacityMap = [];
        if ($bagCodeForSync !== '') {
            $capacityRows = $db->table('bag_receiving_pack_code')
                ->select('brpc_pack_code, brpc_sheet_qty')
                ->where('brpc_lg_idx', $lgIdx)
                ->where('brpc_bag_code', $bagCodeForSync)
                ->where('brpc_pack_code !=', '')
                ->get()
                ->getResultArray();
            foreach ($capacityRows as $cRow) {
                $packCode = trim((string) ($cRow['brpc_pack_code'] ?? ''));
                if ($packCode === '') {
                    continue;
                }
                $capacityMap[$packCode] = max(0, (int) ($cRow['brpc_sheet_qty'] ?? 0));
            }
        }
        foreach ($snapshotRows as $row) {
            $idx = (int) ($row['bisp_idx'] ?? 0);
            if ($idx <= 0) {
                continue;
            }
            $systemQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
            $existingActualRaw = $row['bisp_actual_qty'] ?? null;
            $existingActual = $existingActualRaw === null ? $systemQty : max(0, (int) $existingActualRaw);
            $key = (string) $idx;
            $actualQty = array_key_exists($key, $actualInput)
                ? max(0, (int) $actualInput[$key])
                : $existingActual;
            $packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
            $startCode = trim((string) ($row['bisp_sheet_start_code'] ?? ''));
            $currentEndCode = trim((string) ($row['bisp_sheet_end_code'] ?? ''));
            if ($packCode !== '' && isset($capacityMap[$packCode])) {
                $maxQty = (int) ($capacityMap[$packCode] ?? 0);
                if ($maxQty > 0 && $actualQty > $maxQty) {
                    $db->transRollback();

                    return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                        ->with('error', '팩 ' . $packCode . '의 허용 수량(' . number_format($maxQty) . '장)을 초과했습니다.');
                }
            }
            $nextEndCode = $this->resolveSheetEndCodeByQty($startCode, $currentEndCode, $actualQty);
            $sumActual += $actualQty;
            if (! array_key_exists($key, $actualInput)) {
                continue;
            }
            if ($packCode !== '' && $actualQty !== $existingActual) {
                $changedPackQtyMap[$packCode] = [
                    'qty' => $actualQty,
                    'end_code' => $nextEndCode,
                ];
            }
            $packUpdates[] = [
                'bisp_idx' => $idx,
                'bisp_sheet_qty' => $actualQty,
                'bisp_actual_qty' => $actualQty,
                'bisp_diff_qty' => 0,
                'bisp_sheet_end_code' => $nextEndCode,
                'bisp_checked_yn' => 'Y',
            ];
        }
        if ($packUpdates !== []) {
            $chunk = 500;
            $count = count($packUpdates);
            for ($i = 0; $i < $count; $i += $chunk) {
                $slice = array_slice($packUpdates, $i, $chunk);
                $db->table('bag_inventory_inspection_pack_snapshot')->updateBatch($slice, 'bisp_idx');
            }
        }

        // 같은 봉투코드/팩코드는 다른 실사작업에서도 동일 실사값으로 보이도록 동기화
        // (요구사항: 48에서 12로 저장하면 47에서도 12로 조회)
        if ($bagCodeForSync !== '' && $changedPackQtyMap !== []) {
            foreach ($changedPackQtyMap as $packCode => $meta) {
                $qty = max(0, (int) ($meta['qty'] ?? 0));
                $endCode = (string) ($meta['end_code'] ?? '');
                $db->table('bag_inventory_inspection_pack_snapshot')
                    ->where('bisp_lg_idx', $lgIdx)
                    ->where('bisp_bag_code', $bagCodeForSync)
                    ->where('bisp_pack_code', $packCode)
                    ->update([
                        'bisp_sheet_qty' => $qty,
                        'bisp_actual_qty' => $qty,
                        'bisp_diff_qty' => 0,
                        'bisp_sheet_end_code' => $endCode,
                        'bisp_checked_yn' => 'Y',
                    ]);
            }
        }

        $systemQty = max(0, (int) ($item['bisi_system_qty'] ?? 0));
        $newDiff = $sumActual - $systemQty;
        $prevDiff = (int) ($item['bisi_diff_qty'] ?? 0);
        $alreadyApplied = (string) ($item['bisi_apply_yn'] ?? 'N') === 'Y';
        $applyDelta = $alreadyApplied ? ($newDiff - $prevDiff) : $newDiff;

        $invModel = model(BagInventoryModel::class);
        if ($applyDelta !== 0) {
            $invModel->adjustQty(
                $lgIdx,
                (string) ($item['bisi_bag_code'] ?? ''),
                (string) ($item['bisi_bag_name'] ?? ''),
                $applyDelta
            );
        }

        $db->table('bag_inventory_inspection_item')
            ->where('bisi_idx', $inspectionItemId)
            ->update([
                'bisi_system_qty' => $sumActual,
                'bisi_actual_qty' => $sumActual,
                'bisi_diff_qty' => 0,
                'bisi_apply_yn' => 'Y',
            ]);

        $inspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
        $remain = $db->table('bag_inventory_inspection_item')
            ->where('bisi_bis_idx', $inspectionId)
            ->where('bisi_apply_yn', 'N')
            ->countAllResults();
        $db->table('bag_inventory_inspection')
            ->where('bis_idx', $inspectionId)
            ->update([
                'bis_status' => ($remain === 0) ? 'confirmed' : 'counting',
                'bis_moddate' => date('Y-m-d H:i:s'),
            ]);
        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 저장 중 오류가 발생했습니다.');
        }

        $savedItem = $db->table('bag_inventory_inspection_item')
            ->select('bisi_system_qty, bisi_actual_qty, bisi_apply_yn')
            ->where('bisi_idx', $inspectionItemId)
            ->get()
            ->getRowArray();
        if (
            ! is_array($savedItem)
            || (string) ($savedItem['bisi_apply_yn'] ?? 'N') !== 'Y'
            || (int) ($savedItem['bisi_actual_qty'] ?? -1) !== $sumActual
        ) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 저장 검증에 실패했습니다. 다시 저장해 주세요.');
        }

        return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
            ->with('success', '실사 저장 완료 (합계: ' . number_format($sumActual) . '장)');
    }

    public function inspectionSelectConfirm(): RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }

        $inspectionItemId = (int) ($this->request->getPost('bisi_idx') ?? 0);
        if ($inspectionItemId <= 0) {
            return redirect()->back()->with('error', '실사 대상 품목이 올바르지 않습니다.');
        }
        $returnQuery = $this->inspectionReturnQueryFromPost($inspectionItemId);

        $db = \Config\Database::connect();
        $item = $db->table('bag_inventory_inspection_item')
            ->where('bisi_idx', $inspectionItemId)
            ->get()
            ->getRowArray();
        if (! is_array($item)) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 대상 품목을 찾을 수 없습니다.');
        }
        $requestInspectionId = (int) ($this->request->getPost('bis_id') ?? 0);
        $itemInspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
        if ($requestInspectionId > 0 && $requestInspectionId !== $itemInspectionId) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '선택한 실사 작업과 품목 정보가 일치하지 않습니다.');
        }
        $header = $db->table('bag_inventory_inspection')
            ->select('bis_idx')
            ->where('bis_idx', $itemInspectionId)
            ->where('bis_lg_idx', $lgIdx)
            ->get()
            ->getRowArray();
        if (! is_array($header) || (int) ($header['bis_idx'] ?? 0) <= 0) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 작업 정보가 올바르지 않습니다.');
        }
        if ((string) ($item['bisi_apply_yn'] ?? 'N') === 'Y') {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '이미 확정된 실사 품목입니다.');
        }

        $actualQty = $item['bisi_actual_qty'];
        if ($actualQty === null) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '먼저 실사 수량을 저장해 주세요.');
        }

        $diff = (int) ($item['bisi_diff_qty'] ?? 0);
        $invModel = model(BagInventoryModel::class);
        $db->transStart();
        if ($diff !== 0) {
            $invModel->adjustQty(
                $lgIdx,
                (string) ($item['bisi_bag_code'] ?? ''),
                (string) ($item['bisi_bag_name'] ?? ''),
                $diff
            );
        }
        $db->table('bag_inventory_inspection_item')
            ->where('bisi_idx', $inspectionItemId)
            ->update(['bisi_apply_yn' => 'Y']);

        $inspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
        $remain = $db->table('bag_inventory_inspection_item')
            ->where('bisi_bis_idx', $inspectionId)
            ->where('bisi_apply_yn', 'N')
            ->countAllResults();
        $db->table('bag_inventory_inspection')
            ->where('bis_idx', $inspectionId)
            ->update([
                'bis_status' => ($remain === 0) ? 'confirmed' : 'counting',
                'bis_moddate' => date('Y-m-d H:i:s'),
            ]);
        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
                ->with('error', '실사 확정 중 오류가 발생했습니다.');
        }

        return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
            ->with('success', '실사 결과가 재고에 반영되었습니다.');
    }

    private function inspectionReturnQueryFromPost(int $fallbackItemId): string
    {
        $startDate = trim((string) ($this->request->getPost('start_date') ?? ''));
        $endDate = trim((string) ($this->request->getPost('end_date') ?? ''));
        $bisId = (int) ($this->request->getPost('bis_id') ?? 0);
        $itemCode = trim((string) ($this->request->getPost('item_code') ?? ''));
        $viewType = trim((string) ($this->request->getPost('view_type') ?? 'box'));
        $selItemId = (int) ($this->request->getPost('sel_item_id') ?? $fallbackItemId);
        $selBoxCode = trim((string) ($this->request->getPost('sel_box_code') ?? ''));
        $selPackCode = trim((string) ($this->request->getPost('sel_pack_code') ?? ''));

        return http_build_query([
            'start_date' => $startDate,
            'end_date' => $endDate,
            'bis_id' => $bisId,
            'item_code' => $itemCode,
            'view_type' => $viewType,
            'sel_item_id' => $selItemId > 0 ? $selItemId : $fallbackItemId,
            'sel_box_code' => $selBoxCode,
            'sel_pack_code' => $selPackCode,
        ]);
    }

    public function inspectionDetail(int $id): string|RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }
        $this->ensureInventoryInspectionTables();
        $db = \Config\Database::connect();
        $inspection = $db->table('bag_inventory_inspection')
            ->where('bis_idx', $id)
            ->where('bis_lg_idx', $lgIdx)
            ->get()
            ->getRowArray();
        if (! is_array($inspection)) {
            return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
        }
        $items = $db->table('bag_inventory_inspection_item')
            ->where('bisi_bis_idx', $id)
            ->orderBy('bisi_bag_code', 'ASC')
            ->get()
            ->getResultArray();

        return $this->render('실사 조회', 'bag/inventory_inspection_detail', [
            'inspection' => $inspection,
            'items' => $items,
        ]);
    }

    public function inspectionSave(int $id): RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }
        $this->ensureInventoryInspectionTables();

        $db = \Config\Database::connect();
        $inspection = $db->table('bag_inventory_inspection')
            ->where('bis_idx', $id)
            ->where('bis_lg_idx', $lgIdx)
            ->get()
            ->getRowArray();
        if (! is_array($inspection)) {
            return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
        }

        $actualQtyInput = $this->request->getPost('actual_qty');
        $actualQtyInput = is_array($actualQtyInput) ? $actualQtyInput : [];
        if ($actualQtyInput === []) {
            return redirect()->back()->with('error', '실사 수량을 입력해 주세요.');
        }

        $itemIds = array_values(array_unique(array_map('intval', array_keys($actualQtyInput))));
        $itemIds = array_values(array_filter($itemIds, static fn ($v): bool => $v > 0));
        if ($itemIds === []) {
            return redirect()->back()->with('error', '실사 수량 입력 대상이 없습니다.');
        }

        $rows = $db->table('bag_inventory_inspection_item')
            ->where('bisi_bis_idx', $id)
            ->whereIn('bisi_idx', $itemIds)
            ->get()
            ->getResultArray();
        $rowMap = [];
        foreach ($rows as $r) {
            $rowMap[(int) ($r['bisi_idx'] ?? 0)] = $r;
        }

        $db->transStart();
        foreach ($itemIds as $itemId) {
            if (! isset($rowMap[$itemId])) {
                continue;
            }
            $systemQty = (int) ($rowMap[$itemId]['bisi_system_qty'] ?? 0);
            $actualQty = max(0, (int) ($actualQtyInput[(string) $itemId] ?? 0));
            $diffQty = $actualQty - $systemQty;
            $db->table('bag_inventory_inspection_item')
                ->where('bisi_idx', $itemId)
                ->update([
                    'bisi_actual_qty' => $actualQty,
                    'bisi_diff_qty' => $diffQty,
                ]);
        }
        $db->table('bag_inventory_inspection')
            ->where('bis_idx', $id)
            ->update([
                'bis_status' => 'counted',
                'bis_moddate' => date('Y-m-d H:i:s'),
            ]);
        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->with('error', '실사 저장 중 오류가 발생했습니다.');
        }

        return redirect()->back()->with('success', '실사 수량이 저장되었습니다.');
    }

    public function inspectionApply(int $id): RedirectResponse
    {
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
        }
        $this->ensureInventoryInspectionTables();

        $db = \Config\Database::connect();
        $inspection = $db->table('bag_inventory_inspection')
            ->where('bis_idx', $id)
            ->where('bis_lg_idx', $lgIdx)
            ->get()
            ->getRowArray();
        if (! is_array($inspection)) {
            return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
        }

        $items = $db->table('bag_inventory_inspection_item')
            ->where('bisi_bis_idx', $id)
            ->where('bisi_actual_qty IS NOT NULL', null, false)
            ->where('bisi_apply_yn', 'N')
            ->get()
            ->getResultArray();
        if ($items === []) {
            return redirect()->back()->with('error', '재고 반영할 실사 데이터가 없습니다.');
        }

        $invModel = model(BagInventoryModel::class);
        $db->transStart();
        foreach ($items as $item) {
            $diff = (int) ($item['bisi_diff_qty'] ?? 0);
            if ($diff !== 0) {
                $invModel->adjustQty(
                    $lgIdx,
                    (string) ($item['bisi_bag_code'] ?? ''),
                    (string) ($item['bisi_bag_name'] ?? ''),
                    $diff
                );
            }
            $db->table('bag_inventory_inspection_item')
                ->where('bisi_idx', (int) ($item['bisi_idx'] ?? 0))
                ->update(['bisi_apply_yn' => 'Y']);
        }
        $db->table('bag_inventory_inspection')
            ->where('bis_idx', $id)
            ->update([
                'bis_status' => 'applied',
                'bis_moddate' => date('Y-m-d H:i:s'),
            ]);
        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->with('error', '재고 반영 중 오류가 발생했습니다.');
        }

        return redirect()->back()->with('success', '실사 결과가 재고에 반영되었습니다.');
    }

    private function ensureReceivingPackCodeTableAndBackfill(int $lgIdx): void
    {
        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_receiving_pack_code')) {
            $db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_receiving_pack_code` (
  `brpc_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `brpc_br_idx` INT UNSIGNED NOT NULL,
  `brpc_lg_idx` INT UNSIGNED NOT NULL,
  `brpc_bag_code` VARCHAR(50) NOT NULL,
  `brpc_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
  `brpc_lot_no` VARCHAR(50) NOT NULL DEFAULT '',
  `brpc_box_code` VARCHAR(80) NOT NULL DEFAULT '',
  `brpc_pack_code` VARCHAR(80) NOT NULL,
  `brpc_sheet_start_code` VARCHAR(120) NOT NULL,
  `brpc_sheet_end_code` VARCHAR(120) NOT NULL,
  `brpc_sheet_qty` INT UNSIGNED NOT NULL DEFAULT 0,
  `brpc_state` VARCHAR(20) NOT NULL DEFAULT 'in_stock',
  `brpc_regdate` DATETIME NOT NULL,
  PRIMARY KEY (`brpc_idx`),
  UNIQUE KEY `uk_brpc_pack_code` (`brpc_pack_code`),
  KEY `idx_brpc_br_idx` (`brpc_br_idx`),
  KEY `idx_brpc_lg_bag` (`brpc_lg_idx`,`brpc_bag_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
        }

        $unitRows = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->findAll();
        $unitMap = [];
        foreach ($unitRows as $unit) {
            $unitMap[(string) ($unit->pu_bag_code ?? '')] = [
                'pack_per_sheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
                'total_per_box' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
            ];
        }

        while (true) {
            $missingRows = $db->table('bag_receiving r')
                ->select('r.br_idx, r.br_bo_idx, r.br_bag_code, r.br_bag_name, r.br_qty_sheet, o.bo_lot_no')
                ->join('bag_order o', 'o.bo_idx = r.br_bo_idx', 'left')
                ->join('bag_receiving_pack_code c', 'c.brpc_br_idx = r.br_idx', 'left')
                ->where('r.br_lg_idx', $lgIdx)
                ->where('c.brpc_idx IS NULL', null, false)
                ->orderBy('r.br_idx', 'ASC')
                ->limit(500)
                ->get()
                ->getResultArray();
            if ($missingRows === []) {
                break;
            }

            foreach ($missingRows as $row) {
                $bagCode = (string) ($row['br_bag_code'] ?? '');
                $unit = $unitMap[$bagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
                $this->createReceivingPackCodes(
                    $lgIdx,
                    (int) ($row['br_idx'] ?? 0),
                    (int) ($row['br_bo_idx'] ?? 0),
                    $bagCode,
                    (string) ($row['br_bag_name'] ?? ''),
                    (int) ($row['br_qty_sheet'] ?? 0),
                    (int) ($unit['pack_per_sheet'] ?? 1),
                    (int) ($unit['total_per_box'] ?? 1),
                    (string) ($row['bo_lot_no'] ?? '')
                );
            }
        }
    }

    private function ensureReceivingPackCodesForBag(int $lgIdx, string $bagCode): void
    {
        $bagCode = trim($bagCode);
        if ($lgIdx <= 0 || $bagCode === '') {
            return;
        }
        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_receiving_pack_code')) {
            return;
        }

        $unit = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->where('pu_bag_code', $bagCode)
            ->first();
        $packPerSheet = max(1, (int) ($unit->pu_pack_per_sheet ?? 1));
        $totalPerBox = max(1, (int) ($unit->pu_total_per_box ?? 1));

        while (true) {
            $missingRows = $db->table('bag_receiving r')
                ->select('r.br_idx, r.br_bo_idx, r.br_bag_code, r.br_bag_name, r.br_qty_sheet, o.bo_lot_no')
                ->join('bag_order o', 'o.bo_idx = r.br_bo_idx', 'left')
                ->join('bag_receiving_pack_code c', 'c.brpc_br_idx = r.br_idx', 'left')
                ->where('r.br_lg_idx', $lgIdx)
                ->where('r.br_bag_code', $bagCode)
                ->where('c.brpc_idx IS NULL', null, false)
                ->orderBy('r.br_idx', 'ASC')
                ->limit(200)
                ->get()
                ->getResultArray();
            if ($missingRows === []) {
                break;
            }

            foreach ($missingRows as $row) {
                $this->createReceivingPackCodes(
                    $lgIdx,
                    (int) ($row['br_idx'] ?? 0),
                    (int) ($row['br_bo_idx'] ?? 0),
                    (string) ($row['br_bag_code'] ?? ''),
                    (string) ($row['br_bag_name'] ?? ''),
                    (int) ($row['br_qty_sheet'] ?? 0),
                    $packPerSheet,
                    $totalPerBox,
                    (string) ($row['bo_lot_no'] ?? '')
                );
            }
        }
    }

    private function createReceivingPackCodes(
        int $lgIdx,
        int $brIdx,
        int $boIdx,
        string $bagCode,
        string $bagName,
        int $qtySheet,
        int $packPerSheet,
        int $totalPerBox,
        string $lotNo = ''
    ): void {
        if ($brIdx <= 0 || $qtySheet <= 0 || $bagCode === '') {
            return;
        }

        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_receiving_pack_code')) {
            return;
        }
        $exists = $db->table('bag_receiving_pack_code')
            ->where('brpc_br_idx', $brIdx)
            ->countAllResults();
        if ($exists > 0) {
            return;
        }

        $lotNo = trim($lotNo);
        if ($lotNo === '' && $boIdx > 0) {
            $order = model(BagOrderModel::class)->find($boIdx);
            $lotNo = trim((string) ($order->bo_lot_no ?? ''));
        }
        if ($lotNo === '') {
            $lotNo = $bagCode;
        }

        $packPerSheet = max(1, $packPerSheet);
        $totalPerBox = max(1, $totalPerBox);
        $packsPerBox = max(1, intdiv($totalPerBox, $packPerSheet));
        $packCount = (int) ceil($qtySheet / $packPerSheet);
        $sheetCursor = 1;
        $regdate = date('Y-m-d H:i:s');

        $rows = [];
        for ($packSeq = 1; $packSeq <= $packCount; $packSeq++) {
            $boxSeq = (int) ceil($packSeq / $packsPerBox);
            $sheetQty = min($packPerSheet, max(0, $qtySheet - (($packSeq - 1) * $packPerSheet)));
            if ($sheetQty <= 0) {
                break;
            }
            $sheetStartNo = $sheetCursor;
            $sheetEndNo = $sheetCursor + $sheetQty - 1;
            $sheetCursor = $sheetEndNo + 1;

            $boxCode = sprintf('%s-%06d-B%03d', $lotNo, $brIdx, $boxSeq);
            $packCode = sprintf('%s-%06d-P%03d', $lotNo, $brIdx, $packSeq);
            $startCode = sprintf('%s-S%05d', $packCode, $sheetStartNo);
            $endCode = sprintf('%s-S%05d', $packCode, $sheetEndNo);

            $rows[] = [
                'brpc_br_idx' => $brIdx,
                'brpc_lg_idx' => $lgIdx,
                'brpc_bag_code' => $bagCode,
                'brpc_bag_name' => $bagName !== '' ? $bagName : $bagCode,
                'brpc_lot_no' => $lotNo,
                'brpc_box_code' => $boxCode,
                'brpc_pack_code' => $packCode,
                'brpc_sheet_start_code' => $startCode,
                'brpc_sheet_end_code' => $endCode,
                'brpc_sheet_qty' => $sheetQty,
                'brpc_state' => 'in_stock',
                'brpc_regdate' => $regdate,
            ];
        }
        if ($rows !== []) {
            $db->table('bag_receiving_pack_code')->insertBatch($rows);
        }
    }

    private function ensureInspectionPackSnapshotTable(): void
    {
        $db = \Config\Database::connect();
        if ($db->tableExists('bag_inventory_inspection_pack_snapshot')) {
            return;
        }
        $db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_pack_snapshot` (
  `bisp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `bisp_bisi_idx` INT UNSIGNED NOT NULL,
  `bisp_lg_idx` INT UNSIGNED NOT NULL,
  `bisp_bag_code` VARCHAR(50) NOT NULL,
  `bisp_box_code` VARCHAR(80) NOT NULL DEFAULT '',
  `bisp_pack_code` VARCHAR(80) NOT NULL,
  `bisp_sheet_start_code` VARCHAR(120) NOT NULL,
  `bisp_sheet_end_code` VARCHAR(120) NOT NULL,
  `bisp_sheet_qty` INT UNSIGNED NOT NULL DEFAULT 0,
  `bisp_actual_qty` INT UNSIGNED NULL DEFAULT NULL,
  `bisp_diff_qty` INT NOT NULL DEFAULT 0,
  `bisp_checked_yn` CHAR(1) NOT NULL DEFAULT 'N',
  `bisp_regdate` DATETIME NOT NULL,
  PRIMARY KEY (`bisp_idx`),
  UNIQUE KEY `uk_bisp_item_pack` (`bisp_bisi_idx`,`bisp_pack_code`),
  KEY `idx_bisp_item` (`bisp_bisi_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
    }

    private function ensureInspectionSheetSnapshotTable(): void
    {
        $db = \Config\Database::connect();
        if ($db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
            return;
        }
        $db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_sheet_snapshot` (
  `biss_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `biss_bisi_idx` INT UNSIGNED NOT NULL,
  `biss_lg_idx` INT UNSIGNED NOT NULL,
  `biss_pack_code` VARCHAR(80) NOT NULL,
  `biss_sheet_code` VARCHAR(120) NOT NULL,
  `biss_system_qty` INT UNSIGNED NOT NULL DEFAULT 1,
  `biss_actual_qty` INT UNSIGNED NULL DEFAULT NULL,
  `biss_diff_qty` INT NOT NULL DEFAULT 0,
  `biss_checked_yn` CHAR(1) NOT NULL DEFAULT 'N',
  `biss_regdate` DATETIME NOT NULL,
  PRIMARY KEY (`biss_idx`),
  UNIQUE KEY `uk_biss_item_sheet` (`biss_bisi_idx`,`biss_sheet_code`),
  KEY `idx_biss_item_pack` (`biss_bisi_idx`,`biss_pack_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
    }

    private function ensureInspectionPackSnapshotForItem(int $lgIdx, int $inspectionItemId, bool $forceRebuild = false): void
    {
        if ($inspectionItemId <= 0) {
            return;
        }

        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_inventory_inspection_pack_snapshot')) {
            return;
        }

        $item = $db->table('bag_inventory_inspection_item')
            ->where('bisi_idx', $inspectionItemId)
            ->where('bisi_has_barcode', 'Y')
            ->get()
            ->getRowArray();
        if (! is_array($item)) {
            return;
        }

        $bagCode = trim((string) ($item['bisi_bag_code'] ?? ''));
        if ($bagCode === '') {
            return;
        }
        $this->ensureReceivingPackCodesForBag($lgIdx, $bagCode);

        $sourceRows = $db->table('bag_receiving_pack_code')
            ->select('brpc_box_code, brpc_pack_code, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_bag_code', $bagCode)
            ->where('brpc_state', 'in_stock')
            ->orderBy('brpc_box_code', 'ASC')
            ->orderBy('brpc_pack_code', 'ASC')
            ->get()
            ->getResultArray();
        if ($sourceRows === []) {
            return;
        }

        $existingSnapshot = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('COUNT(*) AS row_cnt', false)
            ->where('bisp_bisi_idx', $inspectionItemId)
            ->get()
            ->getRowArray();
        $existingCount = (int) ($existingSnapshot['row_cnt'] ?? 0);
        // 실사 저장 이후에는 사용자가 수정한 수량을 유지해야 하므로
        // 강제 재생성이 아니면 기존 스냅샷이 존재할 때 재생성하지 않는다.
        if (! $forceRebuild && $existingCount > 0) {
            $this->applyLatestPackAdjustmentsToSnapshot($db, $lgIdx, $inspectionItemId, $bagCode);
            return;
        }

        // 스냅샷은 선택 품목의 현재 in_stock 전체 팩/박스를 기준으로 재생성한다.
        $db->table('bag_inventory_inspection_pack_snapshot')
            ->where('bisp_bisi_idx', $inspectionItemId)
            ->delete();

        $insertRows = [];
        $now = date('Y-m-d H:i:s');
        foreach ($sourceRows as $src) {
            $rowQty = max(0, (int) ($src['brpc_sheet_qty'] ?? 0));
            if ($rowQty <= 0) {
                continue;
            }
            $startCode = (string) ($src['brpc_sheet_start_code'] ?? '');
            $endCode = (string) ($src['brpc_sheet_end_code'] ?? '');

            $insertRows[] = [
                'bisp_bisi_idx' => $inspectionItemId,
                'bisp_lg_idx' => $lgIdx,
                'bisp_bag_code' => $bagCode,
                'bisp_box_code' => (string) ($src['brpc_box_code'] ?? ''),
                'bisp_pack_code' => (string) ($src['brpc_pack_code'] ?? ''),
                'bisp_sheet_start_code' => $startCode,
                'bisp_sheet_end_code' => $endCode,
                'bisp_sheet_qty' => $rowQty,
                'bisp_actual_qty' => $rowQty,
                'bisp_diff_qty' => 0,
                'bisp_checked_yn' => 'N',
                'bisp_regdate' => $now,
            ];
        }
        if ($insertRows !== []) {
            $db->table('bag_inventory_inspection_pack_snapshot')->insertBatch($insertRows);
        }
        $this->applyLatestPackAdjustmentsToSnapshot($db, $lgIdx, $inspectionItemId, $bagCode);
    }

    private function applyLatestPackAdjustmentsToSnapshot(
        \CodeIgniter\Database\BaseConnection $db,
        int $lgIdx,
        int $inspectionItemId,
        string $bagCode
    ): void {
        $bagCode = trim($bagCode);
        if ($inspectionItemId <= 0 || $bagCode === '') {
            return;
        }

        $latestRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_pack_code, MAX(bisp_idx) AS latest_idx', false)
            ->where('bisp_lg_idx', $lgIdx)
            ->where('bisp_bag_code', $bagCode)
            ->where('bisp_checked_yn', 'Y')
            ->where('bisp_pack_code !=', '')
            ->groupBy('bisp_pack_code')
            ->get()
            ->getResultArray();
        if ($latestRows === []) {
            return;
        }

        $latestIdxList = [];
        foreach ($latestRows as $row) {
            $latestIdx = (int) ($row['latest_idx'] ?? 0);
            if ($latestIdx > 0) {
                $latestIdxList[] = $latestIdx;
            }
        }
        if ($latestIdxList === []) {
            return;
        }

        $latestValues = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_pack_code, bisp_sheet_qty')
            ->whereIn('bisp_idx', $latestIdxList)
            ->get()
            ->getResultArray();
        if ($latestValues === []) {
            return;
        }
        $latestMap = [];
        foreach ($latestValues as $row) {
            $packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
            if ($packCode === '') {
                continue;
            }
            $latestMap[$packCode] = [
                'qty' => max(0, (int) ($row['bisp_sheet_qty'] ?? 0)),
            ];
        }
        if ($latestMap === []) {
            return;
        }

        $currentRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_idx, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty')
            ->where('bisp_lg_idx', $lgIdx)
            ->where('bisp_bisi_idx', $inspectionItemId)
            ->where('bisp_bag_code', $bagCode)
            ->get()
            ->getResultArray();
        if ($currentRows === []) {
            return;
        }

        $updates = [];
        foreach ($currentRows as $row) {
            $packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
            if ($packCode === '' || ! isset($latestMap[$packCode])) {
                continue;
            }
            $targetQty = (int) ($latestMap[$packCode]['qty'] ?? 0);
            $currentQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
            $startCode = trim((string) ($row['bisp_sheet_start_code'] ?? ''));
            $currentEndCode = trim((string) ($row['bisp_sheet_end_code'] ?? ''));
            $targetEndCode = $this->resolveSheetEndCodeByQty($startCode, $currentEndCode, $targetQty);
            if ($targetQty === $currentQty && $targetEndCode === $currentEndCode) {
                continue;
            }
            $updates[] = [
                'bisp_idx' => (int) ($row['bisp_idx'] ?? 0),
                'bisp_sheet_qty' => $targetQty,
                'bisp_actual_qty' => $targetQty,
                'bisp_diff_qty' => 0,
                'bisp_sheet_end_code' => $targetEndCode,
                'bisp_checked_yn' => 'Y',
            ];
        }
        if ($updates !== []) {
            $db->table('bag_inventory_inspection_pack_snapshot')->updateBatch($updates, 'bisp_idx');
        }
    }

    private function ensureInspectionSheetSnapshotForItem(int $lgIdx, int $inspectionItemId): void
    {
        if ($inspectionItemId <= 0) {
            return;
        }
        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
            return;
        }
        $exists = $db->table('bag_inventory_inspection_sheet_snapshot')
            ->where('biss_bisi_idx', $inspectionItemId)
            ->countAllResults();
        if ($exists > 0) {
            return;
        }

        $packRows = $db->table('bag_inventory_inspection_pack_snapshot')
            ->select('bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code')
            ->where('bisp_lg_idx', $lgIdx)
            ->where('bisp_bisi_idx', $inspectionItemId)
            ->orderBy('bisp_idx', 'ASC')
            ->get()
            ->getResultArray();
        if ($packRows === []) {
            return;
        }

        $insertRows = [];
        $now = date('Y-m-d H:i:s');
        foreach ($packRows as $packRow) {
            $packCode = (string) ($packRow['bisp_pack_code'] ?? '');
            if ($packCode === '') {
                continue;
            }
            $codes = $this->expandSheetCodes(
                (string) ($packRow['bisp_sheet_start_code'] ?? ''),
                (string) ($packRow['bisp_sheet_end_code'] ?? '')
            );
            foreach ($codes as $codeRow) {
                $sheetCode = (string) ($codeRow['sheet_code'] ?? '');
                if ($sheetCode === '') {
                    continue;
                }
                $insertRows[] = [
                    'biss_bisi_idx' => $inspectionItemId,
                    'biss_lg_idx' => $lgIdx,
                    'biss_pack_code' => $packCode,
                    'biss_sheet_code' => $sheetCode,
                    'biss_system_qty' => 1,
                    'biss_actual_qty' => null,
                    'biss_diff_qty' => 0,
                    'biss_checked_yn' => 'N',
                    'biss_regdate' => $now,
                ];
                if (count($insertRows) >= 1000) {
                    $db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($insertRows);
                    $insertRows = [];
                }
            }
        }
        if ($insertRows !== []) {
            $db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($insertRows);
        }
    }

    private function ensureInspectionSheetSnapshotForPack(
        int $lgIdx,
        int $inspectionItemId,
        string $packCode,
        string $startCode,
        string $endCode
    ): void {
        if ($inspectionItemId <= 0 || trim($packCode) === '') {
            return;
        }
        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
            return;
        }
        $exists = $db->table('bag_inventory_inspection_sheet_snapshot')
            ->where('biss_lg_idx', $lgIdx)
            ->where('biss_bisi_idx', $inspectionItemId)
            ->where('biss_pack_code', $packCode)
            ->countAllResults();
        if ($exists > 0) {
            return;
        }

        $codes = $this->expandSheetCodes($startCode, $endCode);
        if ($codes === []) {
            return;
        }
        $now = date('Y-m-d H:i:s');
        $rows = [];
        foreach ($codes as $codeRow) {
            $sheetCode = trim((string) ($codeRow['sheet_code'] ?? ''));
            if ($sheetCode === '') {
                continue;
            }
            $rows[] = [
                'biss_bisi_idx' => $inspectionItemId,
                'biss_lg_idx' => $lgIdx,
                'biss_pack_code' => $packCode,
                'biss_sheet_code' => $sheetCode,
                'biss_system_qty' => 1,
                'biss_actual_qty' => null,
                'biss_diff_qty' => 0,
                'biss_checked_yn' => 'N',
                'biss_regdate' => $now,
            ];
        }
        if ($rows !== []) {
            $db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($rows);
        }
    }

    private function resolveSheetEndCodeByQty(string $startCode, string $fallbackEndCode, int $qty): string
    {
        if ($qty <= 0) {
            return $fallbackEndCode;
        }
        if (preg_match('/^(.*?)(\d+)$/', $startCode, $m) !== 1) {
            return $fallbackEndCode;
        }
        $prefix = (string) ($m[1] ?? '');
        $startNumRaw = (string) ($m[2] ?? '');
        $startNum = (int) $startNumRaw;
        if ($startNum <= 0) {
            return $fallbackEndCode;
        }
        $endNum = $startNum + $qty - 1;
        $width = strlen($startNumRaw);

        return $prefix . str_pad((string) $endNum, $width, '0', STR_PAD_LEFT);
    }

    /**
     * @param list<array<string,mixed>> $rows
     * @return list<array<string,mixed>>
     */
    private function trimPackRowsToTargetQty(array $rows, int $targetQty): array
    {
        $targetQty = max(0, $targetQty);
        if ($targetQty <= 0 || $rows === []) {
            return [];
        }

        $trimmed = [];
        $remain = $targetQty;
        foreach ($rows as $row) {
            if ($remain <= 0) {
                break;
            }
            $rowQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
            if ($rowQty <= 0) {
                continue;
            }

            if ($rowQty <= $remain) {
                $trimmed[] = $row;
                $remain -= $rowQty;
                continue;
            }

            $startCode = (string) ($row['bisp_sheet_start_code'] ?? '');
            $endCode = (string) ($row['bisp_sheet_end_code'] ?? '');
            $row['bisp_sheet_qty'] = $remain;
            $row['bisp_sheet_end_code'] = $this->resolveSheetEndCodeByQty($startCode, $endCode, $remain);
            $trimmed[] = $row;
            $remain = 0;
        }

        return $trimmed;
    }

    private function ensureInventoryInspectionTables(): void
    {
        $db = \Config\Database::connect();
        if (! $db->tableExists('bag_inventory_inspection')) {
            $db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection` (
  `bis_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `bis_lg_idx` INT UNSIGNED NOT NULL,
  `bis_work_date` DATE NOT NULL,
  `bis_status` VARCHAR(20) NOT NULL DEFAULT 'selected',
  `bis_reg_mb_idx` INT UNSIGNED NOT NULL DEFAULT 0,
  `bis_regdate` DATETIME NOT NULL,
  `bis_moddate` DATETIME NULL DEFAULT NULL,
  PRIMARY KEY (`bis_idx`),
  KEY `idx_bis_lg_work` (`bis_lg_idx`, `bis_work_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
        }
        if (! $db->tableExists('bag_inventory_inspection_item')) {
            $db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_item` (
  `bisi_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `bisi_bis_idx` INT UNSIGNED NOT NULL,
  `bisi_bag_code` VARCHAR(50) NOT NULL,
  `bisi_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
  `bisi_system_qty` INT NOT NULL DEFAULT 0,
  `bisi_actual_qty` INT NULL DEFAULT NULL,
  `bisi_diff_qty` INT NOT NULL DEFAULT 0,
  `bisi_has_barcode` CHAR(1) NOT NULL DEFAULT 'Y',
  `bisi_apply_yn` CHAR(1) NOT NULL DEFAULT 'N',
  PRIMARY KEY (`bisi_idx`),
  KEY `idx_bisi_bis` (`bisi_bis_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
        } else {
            $fields = $db->getFieldNames('bag_inventory_inspection_pack_snapshot');
            if (! in_array('bisp_actual_qty', $fields, true)) {
                $db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_actual_qty` INT UNSIGNED NULL DEFAULT NULL AFTER `bisp_sheet_qty`");
            }
            if (! in_array('bisp_diff_qty', $fields, true)) {
                $db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_diff_qty` INT NOT NULL DEFAULT 0 AFTER `bisp_actual_qty`");
            }
            if (! in_array('bisp_checked_yn', $fields, true)) {
                $db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_checked_yn` CHAR(1) NOT NULL DEFAULT 'N' AFTER `bisp_diff_qty`");
            }
        }
    }

    // ──────────────────────────────────────────────
    // 판매 관리
    // ──────────────────────────────────────────────
    public function sales(): string
    {
        $lgIdx = $this->lgIdx();
        $data = ['salesList' => [], 'orderList' => [], 'startDate' => null, 'endDate' => null];

        if ($lgIdx) {
            $startDate = $this->request->getGet('start_date');
            $endDate   = $this->request->getGet('end_date');
            $data['startDate'] = $startDate;
            $data['endDate']   = $endDate;

            // 판매/반품
            $saleBuilder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx);
            if ($startDate) $saleBuilder->where('bs_sale_date >=', $startDate);
            if ($endDate)   $saleBuilder->where('bs_sale_date <=', $endDate);
            $data['salesList'] = $saleBuilder->orderBy('bs_sale_date', 'DESC')->paginate(20, 'sales');
            $data['salesPager'] = model(BagSaleModel::class)->pager;

            // 주문 접수
            $orderBuilder = model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx);
            if ($startDate) $orderBuilder->where('so_delivery_date >=', $startDate);
            if ($endDate)   $orderBuilder->where('so_delivery_date <=', $endDate);
            $data['orderList'] = $orderBuilder->orderBy('so_idx', 'DESC')->paginate(20, 'shoporders');
            $data['orderPager'] = model(ShopOrderModel::class)->pager;
        }

        return $this->render('판매 관리', 'bag/sales', $data);
    }

    // ──────────────────────────────────────────────
    // 판매 현황
    // ──────────────────────────────────────────────
    public function salesStats(): string
    {
        $lgIdx = $this->lgIdx();
        $data = ['result' => [], 'startDate' => null, 'endDate' => null];

        if ($lgIdx) {
            $startDate = $this->request->getGet('start_date');
            $endDate   = $this->request->getGet('end_date');
            $data['startDate'] = $startDate;
            $data['endDate']   = $endDate;

            $builder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx)->where('bs_type', 'sale');
            if ($startDate) $builder->where('bs_sale_date >=', $startDate);
            if ($endDate)   $builder->where('bs_sale_date <=', $endDate);
            $data['result'] = $builder->orderBy('bs_sale_date', 'DESC')->paginate(20);
            $data['pager'] = model(BagSaleModel::class)->pager;
        }

        return $this->render('판매 현황', 'bag/sales_stats', $data);
    }

    // ──────────────────────────────────────────────
    // 봉투 수불 관리
    // ──────────────────────────────────────────────
    public function flow(): string
    {
        $lgIdx = $this->lgIdx();
        // 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·전체)으로 자동 조회해 바로 표시한다.
        $queried = ($this->request->getGet('search') ?? '1') === '1'
            || $this->request->getGet('start_date') !== null
            || $this->request->getGet('end_date') !== null;

        $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];
        }

        $aggMode = (string) ($this->request->getGet('agg_mode') ?? 'period');
        if (! in_array($aggMode, ['daily', 'period'], true)) {
            $aggMode = 'period';
        }

        $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
        $bagKind = trim((string) ($this->request->getGet('bag_kind') ?? ''));
        $saIdx   = (int) ($this->request->getGet('sa_idx') ?? 0);

        $report = ['rows' => [], 'bagKindLabels' => [], 'queried' => false];
        $bagProducts = [];
        $agencies = [];

        if ($lgIdx) {
            $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
            if ($kindO) {
                foreach (model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
                    $code = (string) ($d->cd_code ?? '');
                    if ($code === '') {
                        continue;
                    }
                    $bagProducts[] = ['code' => $code, 'name' => (string) ($d->cd_name ?? $code)];
                }
            }
            $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
            if ($queried) {
                $report = (new BagFlowReportBuilder())->build(
                    $lgIdx,
                    $startDate,
                    $endDate,
                    $aggMode,
                    $bagCode,
                    $bagKind,
                    $saIdx,
                    true
                );
            }
        }

        $kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
        $bagKindOptions = $kindE
            ? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, $lgIdx)
            : [];

        return $this->render('기간별 봉투 수불 현황', 'bag/flow', [
            'startDate'      => $startDate,
            'endDate'        => $endDate,
            'aggMode'        => $aggMode,
            'bagCode'        => $bagCode,
            'bagKind'        => $bagKind,
            'saIdx'          => $saIdx,
            'bagProducts'    => $bagProducts,
            'bagKindOptions' => $bagKindOptions,
            'agencies'       => $agencies,
            'rows'           => $report['rows'],
            'queried'        => $queried && $lgIdx !== null,
            'exportQuery'    => $this->flowExportQueryString(),
        ]);
    }

    public function flowExport()
    {
        helper(['export', 'admin']);
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(base_url('bag/flow'))->with('error', '지자체를 선택해 주세요.');
        }

        $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
        $endDate   = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
        $aggMode = (string) ($this->request->getGet('agg_mode') ?? 'period');
        if (! in_array($aggMode, ['daily', 'period'], true)) {
            $aggMode = 'period';
        }

        $report = (new BagFlowReportBuilder())->build(
            $lgIdx,
            $startDate,
            $endDate,
            $aggMode,
            trim((string) ($this->request->getGet('bag_code') ?? '')),
            trim((string) ($this->request->getGet('bag_kind') ?? '')),
            (int) ($this->request->getGet('sa_idx') ?? 0),
            true
        );

        $lgName = '';
        $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
        if ($lgRow) {
            $lgName = (string) ($lgRow->lg_name ?? '');
        }

        $aggLabel = $aggMode === 'daily' ? '일자별' : '기간별';
        $metaLines = [
            '출력일: ' . date('Y-m-d'),
            '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')',
            '(단위: 매)',
        ];

        export_bag_flow_report_excel(
            'bag_flow_' . $startDate . '_' . $endDate . '_' . date('Ymd_His'),
            $lgName,
            '기간별 봉투 수불 현황',
            $metaLines,
            $report['rows']
        );

        return null;
    }

    private function flowExportQueryString(): string
    {
        $params = array_filter([
            'search'     => '1',
            'start_date' => $this->request->getGet('start_date'),
            'end_date'   => $this->request->getGet('end_date'),
            'agg_mode'   => $this->request->getGet('agg_mode'),
            'bag_code'   => $this->request->getGet('bag_code'),
            'bag_kind'   => $this->request->getGet('bag_kind'),
            'sa_idx'     => $this->request->getGet('sa_idx'),
        ], static fn ($v) => $v !== null && $v !== '');

        return $params === [] ? 'search=1' : http_build_query($params);
    }

    // ──────────────────────────────────────────────
    // 통계 분석 관리 (w_gm604r / w_gm606r / w_gm607r)
    // ──────────────────────────────────────────────
    public function analytics(): RedirectResponse
    {
        $year = (int) date('Y');

        return redirect()->to(site_url('bag/analytics/year-over-year') . '?' . http_build_query([
            'search' => '1',
            'year'   => $year,
        ]));
    }

    public function analyticsYearOverYear(): string
    {
        $lgIdx = $this->analyticsLgIdx();

        $year = (int) ($this->request->getGet('year') ?? (int) date('Y'));
        if ($year < 2000 || $year > 2100) {
            $year = (int) date('Y');
        }
        $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? ''));
        $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0);
        // 진입 시 기본 조회(현재 연도). search=0 일 때만 미조회
        $queried = $this->request->getGet('search') !== '0';

        $builder = new BagAnalyticsReportBuilder();
        $filters = $builder->loadFilterOptions($lgIdx);
        $report = $builder->buildYearOverYear($lgIdx, $year, $gugunCode, $dsIdx, $queried);

        $gugunLabel = '전체';
        foreach ($filters['gugunOptions'] as $opt) {
            if (($opt['code'] ?? '') === $gugunCode && $gugunCode !== '') {
                $gugunLabel = (string) ($opt['name'] ?? $gugunCode);
                break;
            }
        }

        return $this->render('전년 대비 판매 분석', 'bag/analytics_yoy', [
            'year'       => $year,
            'gugunCode'  => $gugunCode,
            'gugunLabel' => $gugunLabel,
            'dsIdx'      => $dsIdx,
            'queried'    => $queried,
            'filters'    => $filters,
            'report'     => $report,
            'lgName'     => $filters['lgName'],
        ]);
    }

    public function analyticsMonthlyTrend(): string
    {
        $lgIdx = $this->analyticsLgIdx();

        $baseYm = (string) ($this->request->getGet('base_ym') ?? date('Y-m'));
        if (! preg_match('/^\d{4}-\d{2}$/', $baseYm)) {
            $baseYm = date('Y-m');
        }
        $trendBasis = (string) ($this->request->getGet('trend_basis') ?? 'year_avg');
        if (! in_array($trendBasis, ['year_avg', 'month'], true)) {
            $trendBasis = 'year_avg';
        }
        $deviationMin = (float) ($this->request->getGet('deviation_min') ?? 0);
        $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? ''));
        $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0);
        // 진입 시 기본 조회(현재 연·월). search=0 일 때만 미조회
        $queried = $this->request->getGet('search') !== '0';

        $builder = new BagAnalyticsReportBuilder();
        $filters = $builder->loadFilterOptions($lgIdx);
        $report = $builder->buildMonthlyTrend($lgIdx, $baseYm, $trendBasis, $deviationMin, $gugunCode, $saIdx, $queried);
        $lgPickNotice = admin_effective_lg_idx() === null
            ? '작업 지자체가 선택되지 않아 기본 지자체(' . ($filters['lgName'] ?? '') . ') 기준으로 조회합니다. 상단에서 지자체를 선택하면 해당 데이터로 바뀝니다.'
            : '';

        return $this->render('월별 판매 추이 분석', 'bag/analytics_monthly_trend', [
            'baseYm'       => $baseYm,
            'trendBasis'   => $trendBasis,
            'deviationMin' => $deviationMin,
            'gugunCode'    => $gugunCode,
            'saIdx'        => $saIdx,
            'queried'      => $queried,
            'filters'      => $filters,
            'rows'         => $report['rows'] ?? [],
            'reportMeta'   => $report['meta'] ?? [],
            'lgName'       => $filters['lgName'],
            'lgPickNotice' => $lgPickNotice,
        ]);
    }

    public function analyticsSeasonalTrend(): string
    {
        $lgIdx = $this->analyticsLgIdx();

        $baseYear = (int) ($this->request->getGet('base_year') ?? (int) date('Y'));
        if ($baseYear < 2000 || $baseYear > 2100) {
            $baseYear = (int) date('Y');
        }
        $season = BagAnalyticsReportBuilder::normalizeSeason((string) ($this->request->getGet('season') ?? 'spring'));
        $seasonDef = BagAnalyticsReportBuilder::seasonCatalog()[$season];
        $deviationMin = (float) ($this->request->getGet('deviation_min') ?? 0);
        $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? ''));
        // 진입 시 기본 조회(현재 연도). search=0 일 때만 미조회
        $queried = $this->request->getGet('search') !== '0';

        $builder = new BagAnalyticsReportBuilder();
        $filters = $builder->loadFilterOptions($lgIdx);
        $rows = $builder->buildSeasonalTrend($lgIdx, $baseYear, $season, $deviationMin, $gugunCode, $queried);

        return $this->render('계절별 판매 추이 분석', 'bag/analytics_seasonal_trend', [
            'baseYear'          => $baseYear,
            'season'            => $season,
            'seasonLabel'       => $seasonDef['label'],
            'seasonMonthsLabel' => $seasonDef['months_label'],
            'deviationMin'      => $deviationMin,
            'gugunCode'         => $gugunCode,
            'queried'           => $queried,
            'filters'           => $filters,
            'rows'              => $rows,
            'lgName'            => $filters['lgName'],
        ]);
    }

    // ──────────────────────────────────────────────
    // 창 (프로그램 창 관리 - 추후)
    // ──────────────────────────────────────────────
    public function window(): string
    {
        return $this->render('창', 'bag/window', []);
    }

    // ──────────────────────────────────────────────
    // 도움말
    // ──────────────────────────────────────────────
    public function help(): string
    {
        return $this->render('도움말', 'bag/help', []);
    }

    /**
     * 사용자 매뉴얼(설명서) — 목차 첫 페이지로 이동.
     */
    /**
     * 인쇄 활동 기록 (클라이언트 beforeprint beacon 수신).
     * 화면(경로·제목)을 활동 로그에 'print' 액션으로 남긴다.
     */
    public function printLog(): \CodeIgniter\HTTP\ResponseInterface
    {
        $title = trim((string) $this->request->getPost('title'));
        $path  = trim((string) $this->request->getPost('path'));
        if (mb_strlen($title) > 120) {
            $title = mb_substr($title, 0, 120);
        }
        if (mb_strlen($path) > 200) {
            $path = mb_substr($path, 0, 200);
        }
        helper('audit');
        audit_log('print', 'print', 0, null, ['화면' => $title, '경로' => $path]);

        return $this->response->setStatusCode(204);
    }

    public function manual(): \CodeIgniter\HTTP\RedirectResponse
    {
        $first = (new \App\Libraries\ManualRenderer())->firstSlug();
        $url   = site_url('bag/manual/' . $first);
        if ($this->isEmbeddedRequest()) {
            $url .= '?embed=1'; // 워크스페이스 탭 안에서는 임베드 유지(중첩 헤더 방지)
        }

        return redirect()->to($url);
    }

    /**
     * 사용자 매뉴얼 전체 검색 (JSON). q 와 일치하는 페이지·스니펫 목록.
     */
    public function manualSearch(): \CodeIgniter\HTTP\ResponseInterface
    {
        $q       = (string) ($this->request->getGet('q') ?? '');
        $results = (new \App\Libraries\ManualRenderer())->search($q);

        return $this->response->setJSON(['q' => $q, 'results' => $results]);
    }

    /**
     * 사용자 매뉴얼 개별 페이지 (slug = 화이트리스트). 미등록 slug 는 404.
     */
    public function manualPage(string $slug): string
    {
        $renderer = new \App\Libraries\ManualRenderer();
        $page     = $renderer->find($slug);
        $body     = $page !== null ? $renderer->render($slug) : null;
        if ($page === null || $body === null) {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.');
        }

        // 이 매뉴얼 페이지가 설명하는 화면 → 바로가기 링크(screenHelp 역방향).
        // screenHelp 에는 "현재경로 매칭"용 비(非)라우트 prefix(bag/sale, bag/order 등)도 섞여 있어
        // 그대로 쓰면 404가 난다. 같은 소제목(hint)의 후보 중 "실제 GET 라우트"인 첫 경로만 채택.
        $routeCollection = service('routes');
        $routeCollection->loadRoutes();
        $validGet = $routeCollection->getRoutes('GET'); // [경로 => 핸들러] (정적 경로는 키가 곧 경로)

        $screenHelp = config(\Config\Manual::class)->screenHelp ?? [];
        $jumpByHint = [];
        foreach ($screenHelp as $path => $val) {
            [$pSlug, $hint] = array_pad(explode('#', (string) $val, 2), 2, null);
            if ($pSlug !== $slug || $hint === null || trim($hint) === '') {
                continue;
            }
            if (isset($jumpByHint[$hint]) || ! isset($validGet[(string) $path])) {
                continue; // 이미 채택했거나, 실제 라우트가 아니면 건너뜀
            }
            $jumpByHint[$hint] = (string) $path;
        }
        $jumps = [];
        foreach ($jumpByHint as $hint => $path) {
            $jumps[] = ['hint' => $hint, 'url' => base_url($path)];
        }

        return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [
            'pages'   => $renderer->pages(),
            'current' => $slug,
            'title'   => $page['title'],
            'body'    => $body,
            'jumps'   => $jumps,
        ]);
    }

    /**
     * 도움말 — 번호알기(봉투번호확인). 코드 → 바코드·인쇄숫자·인식번호.
     */
    public function numberLookup(): string
    {
        $code   = trim((string) ($this->request->getGet('code') ?? ''));
        $result = null;
        $error  = '';

        if ($code !== '') {
            $resolved = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx());
            $result   = $resolved;
            if (! $resolved['ok']) {
                $error = (string) $resolved['message'];
            }
        }

        return $this->render('번호알기', 'bag/number_lookup', [
            'code'   => $code,
            'result' => $result,
            'error'  => $error,
        ]);
    }

    /**
     * 번호알기 AJAX 조회.
     */
    public function numberLookupResolve()
    {
        $code = trim((string) ($this->request->getPost('code') ?? $this->request->getGet('code') ?? ''));
        $data = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx());

        return $this->response->setJSON($data);
    }

    // ══════════════════════════════════════════════
    //  CRUD — 사이트 레이아웃으로 등록/처리 폼 제공
    // ══════════════════════════════════════════════

    // --- 불출 등록 ---
    public function issueCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();

        $kindO    = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kindO ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : [];

        $bagNameMap = [];
        foreach ($bagCodes as $cd) {
            $bagNameMap[(string) ($cd->cd_code ?? '')] = (string) ($cd->cd_name ?? '');
        }

        $inventoryRows = $lgIdx
            ? model(BagInventoryModel::class)
                ->where('bi_lg_idx', $lgIdx)
                ->where('bi_qty >', 0)
                ->orderBy('bi_bag_code', 'ASC')
                ->findAll()
            : [];
        $inventoryMap = [];
        foreach ($inventoryRows as $inv) {
            $code = (string) ($inv->bi_bag_code ?? '');
            if ($code === '') {
                continue;
            }
            $inventoryMap[$code] = (int) ($inv->bi_qty ?? 0);
        }

        $unitRows = $lgIdx
            ? model(PackagingUnitModel::class)
                ->where('pu_lg_idx', $lgIdx)
                ->where('pu_state', 1)
                ->findAll()
            : [];
        $packagingMap = [];
        foreach ($unitRows as $unit) {
            $code = (string) ($unit->pu_bag_code ?? '');
            if ($code === '') {
                continue;
            }
            $packagingMap[$code] = [
                'packPerSheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
                'totalPerBox'  => max(1, (int) ($unit->pu_total_per_box ?? 1)),
            ];
        }

        $bagMeta = [];
        foreach ($inventoryMap as $code => $qty) {
            $bagMeta[$code] = [
                'name'         => (string) ($bagNameMap[$code] ?? ''),
                'inventoryQty' => (int) $qty,
                'packPerSheet' => (int) (($packagingMap[$code]['packPerSheet'] ?? 1)),
                'totalPerBox'  => (int) (($packagingMap[$code]['totalPerBox'] ?? 1)),
            ];
        }
        $availableBagRows = [];
        foreach ($inventoryMap as $code => $qty) {
            $availableBagRows[] = [
                'bag_code' => (string) $code,
                'bag_name' => (string) ($bagNameMap[$code] ?? $code),
                'inventory_qty' => (int) $qty,
                'pack_per_sheet' => (int) (($packagingMap[$code]['packPerSheet'] ?? 1)),
                'total_per_box' => (int) (($packagingMap[$code]['totalPerBox'] ?? 1)),
            ];
        }

        $recentIssueRows = $lgIdx
            ? model(BagIssueModel::class)
                ->where('bi2_lg_idx', $lgIdx)
                ->orderBy('bi2_issue_date', 'DESC')
                ->orderBy('bi2_idx', 'DESC')
                ->findAll(20)
            : [];

        $kindD = model(CodeKindModel::class)->where('ck_code', 'D')->first();
        $dongCodes = $kindD ? model(CodeDetailModel::class)->getByKind((int) $kindD->ck_idx, true, $lgIdx) : [];
        $today = date('Y-m-d');
        $freeDongRows = [];
        if ($lgIdx) {
            $freeDongRows = model(\App\Models\FreeRecipientModel::class)
                ->builder()
                ->select('fr_dong_code')
                ->distinct()
                ->where('fr_lg_idx', $lgIdx)
                ->where('fr_state', 1)
                ->groupStart()
                    ->where('fr_end_date IS NULL')
                    ->orWhere('fr_end_date >=', $today)
                ->groupEnd()
                ->where('fr_dong_code !=', '')
                ->get()
                ->getResult();
        }
        $freeDongSet = [];
        foreach ($freeDongRows as $row) {
            $code = trim((string) ($row->fr_dong_code ?? ''));
            if ($code !== '') {
                $freeDongSet[$code] = true;
            }
        }

        $destTypeOptions = ['구청', '기타'];
        if ($lgIdx) {
            $typeRows = model(\App\Models\FreeRecipientModel::class)
                ->builder()
                ->select('fr_type_code, fr_name')
                ->distinct()
                ->where('fr_lg_idx', $lgIdx)
                ->where('fr_state', 1)
                ->groupStart()
                    ->where('fr_end_date IS NULL')
                    ->orWhere('fr_end_date >=', $today)
                ->groupEnd()
                ->whereIn('fr_type_code', ['office', 'target'])
                ->orderBy('fr_name', 'ASC')
                ->get()
                ->getResult();
            foreach ($typeRows as $row) {
                $typeCode = trim((string) ($row->fr_type_code ?? ''));
                $name = trim((string) ($row->fr_name ?? ''));
                if ($typeCode === 'office') {
                    $destTypeOptions[] = '동사무소';
                    continue;
                }
                if ($typeCode === 'target' && $name !== '') {
                    $destTypeOptions[] = $name;
                }
            }
            $destTypeOptions = array_values(array_unique($destTypeOptions));
        }

        return $this->render('불출 처리', 'bag/create_bag_issue', compact(
            'bagCodes',
            'bagMeta',
            'inventoryMap',
            'packagingMap',
            'availableBagRows',
            'recentIssueRows',
            'dongCodes',
            'freeDongSet',
            'destTypeOptions'
        ));
    }

    public function issueStore()
    {
        $admin = new \App\Controllers\Admin\BagIssue();
        $admin->initController($this->request, $this->response, service('logger'));
        $result = $admin->store();
        if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
            $to = (string) $result->getHeaderLine('Location');
            $to = str_replace('/admin/bag-issues', '/bag/issue', $to);
            return redirect()->to($to)->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
        }
        return redirect()->to(site_url('bag/issue/cancel'))->with('success', '불출 처리되었습니다.');
    }

    public function issueCancel(int $id)
    {
        $admin = new \App\Controllers\Admin\BagIssue();
        $admin->initController($this->request, $this->response, service('logger'));
        $admin->cancel($id);
        return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
    }

    /**
     * [AJAX] 발주 1건의 내역(헤더+품목)을 JSON으로 반환 — 발주 이력 "보기" 모달·삭제 화면 상세용.
     */
    public function orderDetailJson(int $id)
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
        }
        $order = model(BagOrderModel::class)->find($id);
        if ($order === null || (int) $order->bo_lg_idx !== (int) $lgIdx) {
            return $this->response->setStatusCode(404)->setJSON(['ok' => false, 'error' => '발주를 찾을 수 없습니다.']);
        }

        $companyName = '';
        if ($order->bo_company_idx) {
            $c = model(CompanyModel::class)->find($order->bo_company_idx);
            $companyName = $c ? (string) $c->cp_name : '';
        }
        $agencyName = '';
        if ($order->bo_agency_idx) {
            $a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
            if ($a) {
                $agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? '');
            }
        }
        $managerName = '';
        if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order') && ($order->bo_manager_idx ?? null)) {
            $mg = model(ManagerModel::class)->find($order->bo_manager_idx);
            $managerName = $mg ? (string) $mg->mg_name : '';
        }

        $items = [];
        foreach (model(BagOrderItemModel::class)->where('boi_bo_idx', $id)->orderBy('boi_idx', 'ASC')->findAll() as $it) {
            $items[] = [
                'bag_code'   => (string) ($it->boi_bag_code ?? ''),
                'bag_name'   => (string) ($it->boi_bag_name ?? ''),
                'unit_price' => (float) ($it->boi_unit_price ?? 0),
                'qty_box'    => (int) ($it->boi_qty_box ?? 0),
                'qty_sheet'  => (int) ($it->boi_qty_sheet ?? 0),
                'amount'     => (float) ($it->boi_amount ?? 0),
            ];
        }

        return $this->response->setJSON([
            'ok'          => true,
            'bo_idx'      => (int) $order->bo_idx,
            'bo_lot_no'   => (string) ($order->bo_lot_no ?? ''),
            'bo_version'  => (int) ($order->bo_version ?? 1),
            'order_date'  => (string) ($order->bo_order_date ?? ''),
            'fee_rate'    => (float) ($order->bo_fee_rate ?? 0),
            'status'      => (string) ($order->bo_status ?? ''),
            'company'     => $companyName,
            'agency'      => $agencyName,
            'manager'     => $managerName,
            'items'       => $items,
        ]);
    }

    // --- 발주 등록 ---
    public function orderCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $companies = $lgIdx
            ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll()
            : [];
        $associations = $lgIdx
            ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
            : [];
        $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
        $managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
        $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
        $unitMapRows = $lgIdx ? model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx) : [];
        $recentOrders = $lgIdx
            ? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(12)
            : [];

        $companyMap = [];
        foreach ($companies as $company) {
            $companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
        }
        $agencyMap = [];
        foreach ($agencies as $agency) {
            $agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
        }

        $bagNameMap = [];
        foreach ($bagCodes as $codeDetail) {
            $bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
        }
        $priceMap = [];
        foreach ($priceMapRows as $bagCode => $price) {
            $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
        }
        $unitMap = [];
        foreach ($unitMapRows as $bagCode => $unit) {
            $unitMap[(string) $bagCode] = [
                'boxPerPack' => (int) $unit->pu_box_per_pack,
                'packPerSheet' => (int) $unit->pu_pack_per_sheet,
                'totalPerBox' => (int) $unit->pu_total_per_box,
            ];
        }

        $bagReferenceRows = [];
        foreach ($bagCodes as $codeDetail) {
            $bagCode = (string) $codeDetail->cd_code;
            $unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
            $bagReferenceRows[] = [
                'code' => $bagCode,
                'name' => (string) ($bagNameMap[$bagCode] ?? ''),
                'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
                'boxPerPack' => (int) $unit['boxPerPack'],
                'packPerSheet' => (int) $unit['packPerSheet'],
                'totalPerBox' => (int) $unit['totalPerBox'],
            ];
        }

        return $this->render(
            '발주 등록',
            'bag/create_bag_order',
            array_merge(
                compact(
                    'companies',
                    'associations',
                    'agencies',
                    'managers',
                    'bagCodes',
                    'recentOrders',
                    'companyMap',
                    'agencyMap',
                    'bagReferenceRows'
                ),
                ['editMode' => false, 'editDefaults' => null]
            )
        );
    }

    /**
     * 발주파일 생성(구 'LOT-No 디스켓 불출'): 발주 건을 선택해 암호화 seed 파일 생성/다운로드.
     */
    public function orderLotSeed(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
        $endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
        if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
            $startMonth = date('Y-m');
        }
        if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
            $endMonth = $startMonth;
        }
        if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
            [$startMonth, $endMonth] = [$endMonth, $startMonth];
        }

        $lotNo = trim((string) ($this->request->getGet('lot_no') ?? ''));
        $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
        $startDate = $startMonth . '-01';
        $endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00'));

        $orderModel = model(BagOrderModel::class);
        $builder = $orderModel
            ->where('bo_lg_idx', $lgIdx)
            ->whereLatestHead($lgIdx)
            ->where('bo_order_date >=', $startDate)
            ->where('bo_order_date <=', $endDate)
            ->whereIn('bo_status', ['normal', 'cancelled'])
            ->orderBy('bo_order_date', 'DESC')
            ->orderBy('bo_idx', 'DESC');
        if ($lotNo !== '') {
            $builder->where('bo_lot_no', $lotNo);
        }
        if ($companyIdx > 0) {
            $builder->where('bo_company_idx', $companyIdx);
        }
        $orders = $builder->paginate(20);
        $pager = $orderModel->pager;

        $companies = model(CompanyModel::class)
            ->where('cp_lg_idx', $lgIdx)
            ->where('cp_type', '제작업체')
            ->where('cp_state', 1)
            ->orderBy('cp_name', 'ASC')
            ->findAll();
        $companyMap = [];
        foreach ($companies as $company) {
            $companyMap[(int) ($company->cp_idx ?? 0)] = (string) ($company->cp_name ?? '');
        }

        $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->bo_idx ?? 0), $orders));
        $itemSummary = [];
        if ($orderIds !== []) {
            $items = model(BagOrderItemModel::class)
                ->whereIn('boi_bo_idx', $orderIds)
                ->orderBy('boi_bo_idx', 'ASC')
                ->findAll();
            foreach ($items as $item) {
                $boIdx = (int) ($item->boi_bo_idx ?? 0);
                if (! isset($itemSummary[$boIdx])) {
                    $itemSummary[$boIdx] = [
                        'line_count' => 0,
                        'qty_box' => 0,
                        'qty_sheet' => 0,
                    ];
                }
                $itemSummary[$boIdx]['line_count']++;
                $itemSummary[$boIdx]['qty_box'] += (int) ($item->boi_qty_box ?? 0);
                $itemSummary[$boIdx]['qty_sheet'] += (int) ($item->boi_qty_sheet ?? 0);
            }
        }

        return $this->render('발주파일 생성', 'bag/order_lot_seed', [
            'orders' => $orders,
            'pager' => $pager,
            'startMonth' => $startMonth,
            'endMonth' => $endMonth,
            'lotNo' => $lotNo,
            'companyIdx' => $companyIdx,
            'companies' => $companies,
            'companyMap' => $companyMap,
            'itemSummary' => $itemSummary,
        ]);
    }

    /**
     * 발주파일(seed) 생성·미리보기가 공용으로 쓰는 데이터 구성.
     * 발주 메타(order_data)와 품목(items, 팩/박스 환산 포함), 무결성 해시를 만든다.
     *
     * @return array{ok: bool, error?: string, order?: object, order_data?: array<string,mixed>, items?: array<int,array<string,mixed>>, lot_no?: string, uuid?: string, version?: int, order_hash?: string}
     */
    private function buildLotSeedData(int $lgIdx, int $boIdx): array
    {
        if ($boIdx <= 0) {
            return ['ok' => false, 'error' => '발주 건을 선택해 주세요.'];
        }

        $order = model(BagOrderModel::class)
            ->where('bo_lg_idx', $lgIdx)
            ->where('bo_idx', $boIdx)
            ->first();
        if (! $order) {
            return ['ok' => false, 'error' => '발주 정보를 찾을 수 없습니다.'];
        }

        $lotNo = trim((string) ($order->bo_lot_no ?? ''));
        $uuid = trim((string) ($order->bo_uuid ?? ''));
        $version = max(1, (int) ($order->bo_version ?? 1));
        if ($lotNo === '' || $uuid === '') {
            return ['ok' => false, 'error' => '발주의 LOT/UUID 정보가 없어 발주파일을 생성할 수 없습니다.'];
        }

        $items = model(BagOrderItemModel::class)
            ->where('boi_bo_idx', $boIdx)
            ->orderBy('boi_idx', 'ASC')
            ->findAll();
        if ($items === []) {
            return ['ok' => false, 'error' => '발주 품목이 없어 발주파일을 생성할 수 없습니다.'];
        }

        $unitRows = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->findAll();
        $packMap = [];
        foreach ($unitRows as $unit) {
            $code = trim((string) ($unit->pu_bag_code ?? ''));
            if ($code === '') {
                continue;
            }
            $packMap[$code] = [
                'pack_per_sheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
                'total_per_box' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
            ];
        }

        $orderData = [
            'bo_idx' => $boIdx,
            'bo_uuid' => (string) $uuid,
            'bo_version' => $version,
            'bo_lg_idx' => (int) ($order->bo_lg_idx ?? 0),
            'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''),
            'bo_dong_code' => (string) ($order->bo_dong_code ?? ''),
            'bo_company_idx' => (int) ($order->bo_company_idx ?? 0),
            'bo_agency_idx' => (int) ($order->bo_agency_idx ?? 0),
            'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0),
            'bo_order_date' => (string) ($order->bo_order_date ?? ''),
            'bo_lot_no' => (string) $lotNo,
            'bo_status' => (string) ($order->bo_status ?? 'normal'),
        ];

        $hashItems = [];
        foreach ($items as $item) {
            $code = (string) ($item->boi_bag_code ?? '');
            $pack = $packMap[$code] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
            $qtySheet = max(0, (int) ($item->boi_qty_sheet ?? 0));
            $qtyPack = intdiv($qtySheet, max(1, (int) $pack['pack_per_sheet']));
            $hashItems[] = [
                'boi_idx' => (int) ($item->boi_idx ?? 0),
                'boi_bag_code' => $code,
                'boi_bag_name' => (string) ($item->boi_bag_name ?? ''),
                'boi_unit_price' => (float) ($item->boi_unit_price ?? 0),
                'boi_qty_box' => (int) ($item->boi_qty_box ?? 0),
                'boi_qty_pack' => $qtyPack,
                'boi_qty_sheet' => $qtySheet,
                'pack_per_sheet' => (int) $pack['pack_per_sheet'],
                'total_per_box' => (int) $pack['total_per_box'],
                'boi_amount' => (float) ($item->boi_amount ?? 0),
            ];
        }

        $orderHash = trim((string) ($order->bo_hash ?? ''));
        if ($orderHash === '') {
            $payload = [
                'bo_idx' => $boIdx,
                'order' => $orderData,
                'items' => $hashItems,
            ];
            $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: (string) $boIdx;
            $orderHash = hash('sha256', $payloadJson);
        }

        return [
            'ok'         => true,
            'order'      => $order,
            'order_data' => $orderData,
            'items'      => $hashItems,
            'lot_no'     => $lotNo,
            'uuid'       => $uuid,
            'version'    => $version,
            'order_hash' => $orderHash,
        ];
    }

    /**
     * 발주파일 생성 전 '내용 확인'(미리보기): 암호화 전 평문 내용을 JSON 으로 반환.
     */
    public function orderLotSeedPreview(): ResponseInterface
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setJSON(['ok' => false, 'error' => '지자체를 선택해 주세요.']);
        }

        $boIdx = (int) ($this->request->getGet('bo_idx') ?? 0);
        $data  = $this->buildLotSeedData($lgIdx, $boIdx);
        if (! ($data['ok'] ?? false)) {
            return $this->response->setJSON(['ok' => false, 'error' => (string) ($data['error'] ?? '내용을 확인할 수 없습니다.')]);
        }

        $order      = $data['order'];
        $companyIdx = (int) ($order->bo_company_idx ?? 0);
        $companyName = '';
        if ($companyIdx > 0) {
            $company = model(CompanyModel::class)
                ->where('cp_lg_idx', $lgIdx)
                ->where('cp_idx', $companyIdx)
                ->first();
            $companyName = (string) ($company->cp_name ?? '');
        }

        $items = array_map(static fn (array $it): array => [
            'bag_code'   => (string) $it['boi_bag_code'],
            'bag_name'   => (string) $it['boi_bag_name'],
            'unit_price' => (float) $it['boi_unit_price'],
            'qty_box'    => (int) $it['boi_qty_box'],
            'qty_pack'   => (int) $it['boi_qty_pack'],
            'qty_sheet'  => (int) $it['boi_qty_sheet'],
            'amount'     => (float) $it['boi_amount'],
        ], $data['items']);

        return $this->response->setJSON([
            'ok'         => true,
            'bo_idx'     => $boIdx,
            'lot_no'     => $data['lot_no'],
            'uuid'       => $data['uuid'],
            'version'    => $data['version'],
            'order_date' => (string) ($order->bo_order_date ?? ''),
            'company'    => $companyName,
            'order_hash' => $data['order_hash'],
            'items'      => $items,
            'totals'     => [
                'line_count' => count($items),
                'qty_box'    => array_sum(array_map(static fn ($i) => (int) $i['qty_box'], $items)),
                'qty_sheet'  => array_sum(array_map(static fn ($i) => (int) $i['qty_sheet'], $items)),
                'amount'     => array_sum(array_map(static fn ($i) => (float) $i['amount'], $items)),
            ],
        ]);
    }

    public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
        $data  = $this->buildLotSeedData($lgIdx, $boIdx);
        if (! ($data['ok'] ?? false)) {
            return redirect()->back()->with('error', (string) ($data['error'] ?? '발주파일을 생성할 수 없습니다.'));
        }

        $seedPath = $this->generateLotSeedFile(
            $data['uuid'],
            $data['version'],
            $data['lot_no'],
            $data['order_data'],
            $data['items'],
            $data['order_hash']
        );
        $seedBinary = @file_get_contents($seedPath);
        if (! is_string($seedBinary) || $seedBinary === '') {
            return redirect()->back()->with('error', '발주파일 생성에는 성공했으나 파일을 읽을 수 없습니다.');
        }

        return $this->response
            ->download($seedPath, $seedBinary)
            ->setFileName(basename($seedPath));
    }

    /**
     * @param array<string,mixed> $orderData
     * @param array<int,array<string,mixed>> $items
     */
    private function generateLotSeedFile(string $uuid, int $version, string $lotNo, array $orderData, array $items, string $orderHash): string
    {
        $baseDir = WRITEPATH . 'barcode-seeds';
        if (! is_dir($baseDir)) {
            mkdir($baseDir, 0775, true);
        }
        $keyDir = WRITEPATH . 'keys';
        if (! is_dir($keyDir)) {
            mkdir($keyDir, 0775, true);
        }

        $privateKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_private.pem';
        $publicKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_public.pem';
        if (! is_file($privateKeyPath) || ! is_file($publicKeyPath)) {
            $config = [
                'private_key_bits' => 2048,
                'private_key_type' => OPENSSL_KEYTYPE_RSA,
            ];
            $resource = openssl_pkey_new($config);
            if ($resource !== false) {
                $privatePem = '';
                openssl_pkey_export($resource, $privatePem);
                $details = openssl_pkey_get_details($resource);
                $publicPem = $details['key'] ?? '';
                if ($privatePem !== '' && $publicPem !== '') {
                    file_put_contents($privateKeyPath, $privatePem);
                    file_put_contents($publicKeyPath, $publicPem);
                }
            }
        }

        $payload = [
            'uuid' => $uuid,
            'version' => $version,
            'lot_no' => $lotNo,
            'order_hash' => $orderHash,
            'order' => $orderData,
            'items' => $items,
        ];
        $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';

        $aesKey = random_bytes(32);
        $iv = random_bytes(16);
        $cipherRaw = openssl_encrypt($payloadJson, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
        if ($cipherRaw === false) {
            $cipherRaw = $payloadJson;
        }

        $encryptedKey = '';
        $publicPem = is_file($publicKeyPath) ? file_get_contents($publicKeyPath) : '';
        if (is_string($publicPem) && $publicPem !== '') {
            openssl_public_encrypt($aesKey, $encryptedKey, $publicPem, OPENSSL_PKCS1_OAEP_PADDING);
        }

        $seed = [
            'algorithm' => ['symmetric' => 'AES-256-CBC', 'asymmetric' => 'RSA-2048'],
            'lot_no' => $lotNo,
            'uuid' => $uuid,
            'version' => $version,
            'iv_b64' => base64_encode($iv),
            'key_b64' => $encryptedKey !== '' ? base64_encode($encryptedKey) : '',
            'cipher_b64' => base64_encode((string) $cipherRaw),
            'payload_hash' => hash('sha256', $payloadJson),
            'created_at' => date('c'),
        ];

        $fileName = sprintf('%s_v%d_diskette_%s.seed.json', $lotNo, $version, date('Ymd_His'));
        $fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName;
        file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

        return $fullPath;
    }

    /**
     * 발주 변경 허브: 발주월·변경 구분 선택 후 목록에서 발주를 선택 (GBMS 발주 변경 화면 흐름).
     */
    public function orderChange(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $month = $this->request->getGet('month');
        if ($month === null || $month === '' || ! is_string($month) || ! preg_match('/^\d{4}-\d{2}$/', $month)) {
            $month = date('Y-m');
        }

        $hubMode = $this->request->getGet('hub_mode');
        $hubMode = in_array($hubMode, ['price', 'meta', 'delete'], true) ? $hubMode : 'meta';

        $companyMap = [];
        if ($lgIdx) {
            $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
            foreach ($companies as $company) {
                $companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
            }
        }

        $monthOrders = [];
        if ($lgIdx) {
            $start = $month . '-01';
            $end   = date('Y-m-t', strtotime($start . ' 00:00:00'));
            $monthOrders = model(BagOrderModel::class)
                ->where('bo_lg_idx', $lgIdx)
                ->whereLatestHead($lgIdx)
                ->where('bo_order_date >=', $start)
                ->where('bo_order_date <=', $end)
                ->whereIn('bo_status', ['normal', 'cancelled'])
                ->orderBy('bo_order_date', 'DESC')
                ->orderBy('bo_idx', 'DESC')
                ->findAll();
        }

        if ($hubMode === 'delete') {
            foreach ($monthOrders as $row) {
                if ((string) ($row->bo_status ?? '') === 'normal') {
                    return redirect()->to(
                        site_url('bag/order/revise/' . (int) $row->bo_idx . '?change_mode=delete')
                    );
                }
            }
            if ($lgIdx) {
                session()->setFlashdata('error', '해당 월에 삭제할 수 있는 발주(정상)가 없습니다.');
            }
        }

        return $this->render(
            '발주 변경',
            'bag/order_change',
            compact('month', 'hubMode', 'monthOrders', 'companyMap')
        );
    }

    public function orderRevise(int $id): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $orderModel = model(BagOrderModel::class);
        $itemModel = model(\App\Models\BagOrderItemModel::class);

        $target = $orderModel->find($id);
        if (! $target || (int) $target->bo_lg_idx !== $lgIdx) {
            return redirect()->to(site_url('bag/order/change'))->with('error', '수정할 발주를 찾을 수 없습니다.');
        }
        if ((string) ($target->bo_status ?? '') !== 'normal') {
            return redirect()->to(site_url('bag/order/change'))->with('error', '변경할 수 없는 발주입니다.');
        }

        $changeMode = $this->request->getGet('change_mode');
        $changeMode = in_array($changeMode, ['price', 'meta', 'delete'], true) ? $changeMode : 'meta';

        $companies = $lgIdx
            ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll()
            : [];
        $associations = $lgIdx
            ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
            : [];
        $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
        $managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
        $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
        $unitMapRows = $lgIdx ? model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx) : [];
        $companyMap = [];
        foreach ($companies as $company) {
            $companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
        }
        $agencyMap = [];
        foreach ($agencies as $agency) {
            $agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
        }

        $bagNameMap = [];
        foreach ($bagCodes as $codeDetail) {
            $bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
        }
        $priceMap = [];
        foreach ($priceMapRows as $bagCode => $price) {
            $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
        }
        $unitMap = [];
        foreach ($unitMapRows as $bagCode => $unit) {
            $unitMap[(string) $bagCode] = [
                'boxPerPack' => (int) $unit->pu_box_per_pack,
                'packPerSheet' => (int) $unit->pu_pack_per_sheet,
                'totalPerBox' => (int) $unit->pu_total_per_box,
            ];
        }

        $bagReferenceRows = [];
        foreach ($bagCodes as $codeDetail) {
            $bagCode = (string) $codeDetail->cd_code;
            $unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
            $bagReferenceRows[] = [
                'code' => $bagCode,
                'name' => (string) ($bagNameMap[$bagCode] ?? ''),
                'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
                'boxPerPack' => (int) $unit['boxPerPack'],
                'packPerSheet' => (int) $unit['packPerSheet'],
                'totalPerBox' => (int) $unit['totalPerBox'],
            ];
        }

        $items = $itemModel->where('boi_bo_idx', (int) $target->bo_idx)->orderBy('boi_idx', 'ASC')->findAll();

        $orderReturnMonth = substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7);
        $monthStart       = $orderReturnMonth . '-01';
        $monthEnd         = date('Y-m-t', strtotime($monthStart . ' 00:00:00'));
        $recentOrders     = $lgIdx
            ? $orderModel->where('bo_lg_idx', $lgIdx)
                ->whereLatestHead($lgIdx)
                ->where('bo_order_date >=', $monthStart)
                ->where('bo_order_date <=', $monthEnd)
                ->whereIn('bo_status', ['normal', 'cancelled'])
                ->orderBy('bo_order_date', 'DESC')
                ->orderBy('bo_idx', 'DESC')
                ->findAll()
            : [];
        $itemCodes = [];
        $itemQtyBoxes = [];
        $itemQtySheets = [];
        foreach ($items as $item) {
            $itemCodes[] = (string) ($item->boi_bag_code ?? '');
            $itemQtyBoxes[] = (int) ($item->boi_qty_box ?? 0);
            $itemQtySheets[] = (int) ($item->boi_qty_sheet ?? 0);
        }

        $savedLinePrices = [];
        foreach ($items as $item) {
            $savedLinePrices[(string) ($item->boi_bag_code ?? '')] = (float) ($item->boi_unit_price ?? 0);
        }
        foreach ($bagReferenceRows as &$brow) {
            $c = (string) ($brow['code'] ?? '');
            if ($c !== '' && isset($savedLinePrices[$c])) {
                $brow['orderPrice'] = $savedLinePrices[$c];
            }
        }
        unset($brow);

        $orderLotNo = (string) ($target->bo_lot_no ?? '');

        $editDefaults = [
            'bo_source_idx' => (int) $target->bo_idx,
            'bo_order_date' => (string) ($target->bo_order_date ?? date('Y-m-d')),
            'bo_order_month_ui' => substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7),
            'bo_fee_rate' => (string) ($target->bo_fee_rate ?? '0'),
            'bo_association_idx' => (string) ($target->bo_association_idx ?? ''),
            'bo_company_idx' => (string) ($target->bo_company_idx ?? ''),
            'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''),
            'bo_manager_idx' => (string) ($target->bo_manager_idx ?? ''),
            'item_bag_code' => $itemCodes,
            'item_qty_box' => $itemQtyBoxes,
            'item_qty_sheet' => $itemQtySheets,
        ];

        return $this->render(
            '발주 변경',
            'bag/create_bag_order',
            compact(
                'companies',
                'associations',
                'agencies',
                'managers',
                'bagCodes',
                'recentOrders',
                'companyMap',
                'agencyMap',
                'bagReferenceRows',
                'editDefaults',
                'changeMode',
                'orderReturnMonth',
                'orderLotNo'
            )
                + ['editMode' => true, 'hubReturn' => true]
        );
    }

    public function orderStore()
    {
        $admin = new \App\Controllers\Admin\BagOrder();
        $admin->initController($this->request, $this->response, service('logger'));
        $result = $admin->store();
        if ($result instanceof RedirectResponse) {
            $success = session()->getFlashdata('success');
            $error   = session()->getFlashdata('error');
            $errors  = session()->getFlashdata('errors');

            if (! empty($error) || ! empty($errors)) {
                $sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0);
                $reviseMode = (string) ($this->request->getPost('bo_change_mode') ?? 'meta');
                $redirectUrl = $sourceIdx > 0
                    ? site_url('bag/order/revise/' . $sourceIdx . '?change_mode=' . rawurlencode($reviseMode))
                    : site_url('bag/order/create');

                return redirect()->to($redirectUrl)
                    ->withInput()
                    ->with('error', $error)
                    ->with('errors', $errors);
            }

            $returnHub     = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1;
            $returnMonth   = (string) ($this->request->getPost('order_return_month') ?? '');
            $sourceIdxPost = (int) ($this->request->getPost('bo_source_idx') ?? 0);
            if ($returnHub && $sourceIdxPost > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) {
                return redirect()->to(site_url('bag/order/change?month=' . $returnMonth))
                    ->with('success', $success ?? '발주가 저장되었습니다.');
            }

            return redirect()->to(site_url('bag/order/create'))
                ->with('success', $success);
        }

        $returnHub   = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1;
        $returnMonth = (string) ($this->request->getPost('order_return_month') ?? '');
        if ($returnHub && (int) ($this->request->getPost('bo_source_idx') ?? 0) > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) {
            return redirect()->to(site_url('bag/order/change?month=' . $returnMonth))->with('success', '발주가 저장되었습니다.');
        }

        return redirect()->to(site_url('bag/order/create'))->with('success', '발주 등록되었습니다.');
    }

    public function orderDeletePost()
    {
        $id = (int) ($this->request->getPost('bo_idx') ?? 0);
        if ($id <= 0) {
            return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 발주를 선택해 주세요.');
        }

        return $this->orderDelete($id);
    }

    public function orderDelete(int $id)
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if ($lgIdx === null || $lgIdx <= 0) {
            return redirect()->to(site_url('bag/order/change'))->with('error', '지자체를 선택해 주세요.');
        }
        $orderModel = model(BagOrderModel::class);
        $order      = $orderModel->find($id);
        if (! $order || (int) $order->bo_lg_idx !== $lgIdx) {
            return redirect()->to(site_url('bag/order/change'))->with('error', '발주를 찾을 수 없습니다.');
        }
        if ((string) ($order->bo_status ?? '') !== 'normal') {
            return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 수 없는 발주입니다.');
        }
        $month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7);

        // 삭제 후 돌아갈 화면: 발주 등록에서 삭제한 경우 등록 화면으로 복귀
        $returnTo = (string) ($this->request->getPost('return_to') ?? '');
        $redirectUrl = $returnTo === 'create'
            ? site_url('bag/order/create')
            : site_url('bag/order/change?month=' . $month);

        $admin = new \App\Controllers\Admin\BagOrder();
        $admin->initController($this->request, $this->response, service('logger'));
        $response = $admin->delete($id);
        if ($response instanceof RedirectResponse) {
            $msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.';

            return redirect()->to($redirectUrl)->with('success', $msg);
        }

        return redirect()->to($redirectUrl)->with('success', '처리되었습니다.');
    }

    public function orderCancel(int $id)
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (!$lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 확인할 수 없습니다.');
        }
        $orderModel = model(BagOrderModel::class);
        $order      = $orderModel->find($id);
        if (!$order || (int) $order->bo_lg_idx !== $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '발주를 찾을 수 없습니다.');
        }
        $before = (array) $order;
        $orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
        helper('audit');
        audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']);

        return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '발주가 취소되었습니다.');
    }

    // --- 입고 처리 ---
    public function receivingCreate(): string
    {
        return $this->receivingScanner();
    }

    public function receivingStore()
    {
        return $this->receivingScannerStore();
    }

    /**
     * 발주 입고(스캐너 대체 수동입력)
     * - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리
     * - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자
     * - 인계자: 제작업체(company) 담당자
     */
    public function receivingScanner(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0));
        $lotNo = '';
        $bagCode = '';

        $companies = model(CompanyModel::class)
            ->where('cp_lg_idx', $lgIdx)
            ->where('cp_type', '제작업체')
            ->where('cp_state', 1)
            ->orderBy('cp_name', 'ASC')
            ->findAll();

        $defaultCompanyIdx = ! empty($companies)
            ? (int) ($companies[0]->cp_idx ?? 0)
            : 0;

        if ($companyIdx > 0) {
            $validCompany = false;
            foreach ($companies as $company) {
                if ((int) ($company->cp_idx ?? 0) === $companyIdx) {
                    $validCompany = true;
                    break;
                }
            }
            if (! $validCompany) {
                $companyIdx = $defaultCompanyIdx;
            }
        } elseif ($defaultCompanyIdx > 0) {
            // 초기 진입 시 드롭다운 최상단 제작업체를 기본 선택한다.
            $companyIdx = $defaultCompanyIdx;
        }

        $lotChoices = [];
        $bagFilterOptions = $this->receivingBagFilterOptions($lgIdx, $companyIdx, '');

        $pick = $this->receivingManagerPickers($lgIdx);
        $recvSel = $this->receivingReceiverSelect($lgIdx);
        $receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
        $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
        if ($receiverRef === '') {
            $receiverRef = $recvSel['defaultReceiverRef'];
        }
        $senderIdx = (int) old('br_sender_idx', $pick['defaultSenderIdx']);

        $rows = $companyIdx > 0
            ? $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '')
            : [];
        $rowsByKey = [];
        foreach ($rows as $row) {
            $rowsByKey[(string) $row['row_key']] = $row;
        }

        return $this->render(
            '발주 입고(스캐너)',
            'bag/receiving_scanner',
            [
                'companyIdx' => $companyIdx,
                'companies' => $companies,
                'lotNo' => '',
                'bagCode' => '',
                'bagFilterOptions' => $bagFilterOptions,
                'lotChoices' => $lotChoices,
                'receiverOptions' => $recvSel['receiverOptions'],
                'receiverRef' => $receiverRef,
                'senders' => $pick['senders'],
                'senderIdx' => $senderIdx,
                'rows' => $rows,
                'rowsByKey' => $rowsByKey,
            ]
        );
    }

    public function receivingScannerStore(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
        $companyIdx = (int) ($this->request->getPost('company_idx') ?? 0);
        $lotNo = '';
        $filterBagCode = '';
        $recvSel = $this->receivingReceiverSelect($lgIdx);
        $receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
        $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
        if ($receiverRef === '') {
            $receiverRef = $recvSel['defaultReceiverRef'];
        }
        $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
        $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
        $inputQty = $this->request->getPost('receive_qty_sheet');
        $inputQty = is_array($inputQty) ? $inputQty : [];

        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
            return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.');
        }
        if ($companyIdx <= 0) {
            return redirect()->back()->withInput()->with('error', '제작업체를 선택해 주세요.');
        }
        if ($receiverIdx <= 0) {
            return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.');
        }

        $senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);

        $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '');
        $rowMap = [];
        foreach ($rows as $row) {
            $rowMap[(string) $row['row_key']] = $row;
        }

        $insertRows = [];
        foreach ($inputQty as $rowKey => $qtyRaw) {
            $rowKey = (string) $rowKey;
            $qty = (int) $qtyRaw;
            if ($qty <= 0 || ! isset($rowMap[$rowKey])) {
                continue;
            }
            $base = $rowMap[$rowKey];
            $pending = (int) ($base['pending_qty_sheet'] ?? 0);
            if ($pending <= 0) {
                continue;
            }
            if ($qty > $pending) {
                $qty = $pending;
            }
            $totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1));
            $qtyBox = intdiv($qty, $totalPerBox);
            $sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? '');

            $insertRows[] = [
                'br_bo_idx'       => (int) $base['bo_idx'],
                'br_lg_idx'       => $lgIdx,
                'br_bag_code'     => (string) $base['bag_code'],
                'br_bag_name'     => (string) $base['bag_name'],
                'br_qty_box'      => $qtyBox,
                'br_qty_sheet'    => $qty,
                'br_receive_date' => $receiveDate,
                'br_receiver_idx' => $receiverIdx,
                'br_sender_name'  => $sender,
                'br_type'         => 'scanner',
                'br_regdate'      => date('Y-m-d H:i:s'),
            ];
        }

        if (empty($insertRows)) {
            return redirect()->back()->withInput()->with('error', '입고 처리할 수량을 입력해 주세요.');
        }

        $recvModel = model(BagReceivingModel::class);
        $invModel = model(BagInventoryModel::class);
        $db = \Config\Database::connect();
        $db->transStart();
        foreach ($insertRows as $row) {
            $recvModel->insert($row);
            $brIdx = (int) $recvModel->getInsertID();
            $invModel->adjustQty(
                $lgIdx,
                (string) $row['br_bag_code'],
                (string) $row['br_bag_name'],
                (int) $row['br_qty_sheet']
            );
            $this->createReceivingPackCodes(
                $lgIdx,
                $brIdx,
                (int) $row['br_bo_idx'],
                (string) $row['br_bag_code'],
                (string) $row['br_bag_name'],
                (int) $row['br_qty_sheet'],
                max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['pack_per_sheet'] ?? 1)),
                max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['total_per_box'] ?? 1))
            );
        }
        $db->transComplete();

        if (! $db->transStatus()) {
            return redirect()->back()->withInput()->with('error', '입고 처리 중 오류가 발생했습니다.');
        }

        $query = ['company_idx' => $companyIdx];

        return redirect()->to(site_url('bag/receiving/scanner') . '?' . http_build_query($query))
            ->with('success', count($insertRows) . '건 입고 처리되었습니다.');
    }

    /**
     * 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고.
     */
    public function receivingBatch(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
        $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));

        $companies = model(CompanyModel::class)
            ->where('cp_lg_idx', $lgIdx)
            ->where('cp_type', '제작업체')
            ->where('cp_state', 1)
            ->orderBy('cp_name', 'ASC')
            ->findAll();

        $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        $pick = $this->receivingManagerPickers($lgIdx);
        $recvSel = $this->receivingReceiverSelect($lgIdx);
        $receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
        $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
        if ($receiverRef === '') {
            $receiverRef = $recvSel['defaultReceiverRef'];
        }
        // 조회 화면에서는 입고완료 행도 함께 보여 미입고량 0을 확인할 수 있게 한다.
        $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');

        return $this->render(
            '일괄 입고',
            'bag/receiving_batch',
            [
                'companyIdx' => $companyIdx,
                'bagCode' => $bagCode,
                'companies' => $companies,
                'bagCodeOptions' => $bagCodeOptions,
                'receiverOptions' => $recvSel['receiverOptions'],
                'receiverRef' => $receiverRef,
                'senders' => $pick['senders'],
                'senderIdx' => (int) old('br_sender_idx', $pick['defaultSenderIdx']),
                'rows' => $rows,
            ]
        );
    }

    public function receivingBatchStore(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $companyIdx = (int) ($this->request->getPost('company_idx') ?? 0);
        $bagCode = trim((string) ($this->request->getPost('bag_code') ?? ''));
        $selected = $this->request->getPost('selected_rows');
        $selected = is_array($selected) ? array_map('strval', $selected) : [];
        $receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
        $recvSel = $this->receivingReceiverSelect($lgIdx);
        $receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
        $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
        if ($receiverRef === '') {
            $receiverRef = $recvSel['defaultReceiverRef'];
        }
        $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
        $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);

        // 오류 시 명시적으로 일괄 입고 화면으로 복귀(back()은 도움말 드로어 등이 남긴
        // previous_url 로 잘못 이동할 수 있어 사용하지 않는다)
        $batchUrl = site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode));

        if (empty($selected)) {
            return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.');
        }
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
            return redirect()->to($batchUrl)->withInput()->with('error', '입고일 형식을 확인해 주세요.');
        }
        if ($receiverIdx <= 0) {
            return redirect()->to($batchUrl)->withInput()->with('error', '인수자를 선택해 주세요.');
        }

        $senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);

        $rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, '');
        $rowMap = [];
        foreach ($rows as $row) {
            $rowMap[(string) $row['row_key']] = $row;
        }

        $insertRows = [];
        foreach ($selected as $rowKey) {
            if (! isset($rowMap[$rowKey])) {
                continue;
            }
            $base = $rowMap[$rowKey];
            $qty = (int) ($base['pending_qty_sheet'] ?? 0);
            if ($qty <= 0) {
                continue;
            }
            $totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1));
            $qtyBox = intdiv($qty, $totalPerBox);
            $sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? '');

            $insertRows[] = [
                'br_bo_idx'       => (int) $base['bo_idx'],
                'br_lg_idx'       => $lgIdx,
                'br_bag_code'     => (string) $base['bag_code'],
                'br_bag_name'     => (string) $base['bag_name'],
                'br_qty_box'      => $qtyBox,
                'br_qty_sheet'    => $qty,
                'br_receive_date' => $receiveDate,
                'br_receiver_idx' => $receiverIdx,
                'br_sender_name'  => $sender,
                'br_type'         => 'batch',
                'br_regdate'      => date('Y-m-d H:i:s'),
            ];
        }

        if (empty($insertRows)) {
            return redirect()->to($batchUrl)->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.');
        }

        $recvModel = model(BagReceivingModel::class);
        $invModel = model(BagInventoryModel::class);
        $db = \Config\Database::connect();
        $db->transStart();
        foreach ($insertRows as $row) {
            $recvModel->insert($row);
            $brIdx = (int) $recvModel->getInsertID();
            $invModel->adjustQty(
                $lgIdx,
                (string) $row['br_bag_code'],
                (string) $row['br_bag_name'],
                (int) $row['br_qty_sheet']
            );
            $this->createReceivingPackCodes(
                $lgIdx,
                $brIdx,
                (int) $row['br_bo_idx'],
                (string) $row['br_bag_code'],
                (string) $row['br_bag_name'],
                (int) $row['br_qty_sheet'],
                max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['pack_per_sheet'] ?? 1)),
                max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['total_per_box'] ?? 1))
            );
        }
        $db->transComplete();

        if (! $db->transStatus()) {
            return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.');
        }

        return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode)))
            ->with('success', count($insertRows) . '건 일괄 입고 처리되었습니다.');
    }

    public function receivingStatus(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
        $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
        $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
        $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
        $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
        if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) {
            $receiveType = 'all';
        }

        $companies = model(CompanyModel::class)
            ->where('cp_lg_idx', $lgIdx)
            ->where('cp_type', '제작업체')
            ->where('cp_state', 1)
            ->orderBy('cp_name', 'ASC')
            ->findAll();
        $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];

        $rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType);
        $groupTotals = [];
        $grandTotalReceive = 0;
        foreach ($rows as $row) {
            $key = (string) ($row['display_date'] ?? '');
            if (! isset($groupTotals[$key])) {
                $groupTotals[$key] = 0;
            }
            $groupTotals[$key] += (int) ($row['received_qty_sheet'] ?? 0);
            $grandTotalReceive += (int) ($row['received_qty_sheet'] ?? 0);
        }

        return $this->render(
            '입고 현황',
            'bag/receiving_status',
            compact(
                'startDate',
                'endDate',
                'companyIdx',
                'bagCode',
                'receiveType',
                'companies',
                'bagCodeOptions',
                'rows',
                'groupTotals',
                'grandTotalReceive'
            )
        );
    }

    public function receivingStatusExport(): RedirectResponse
    {
        helper(['admin', 'export']);
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
        }

        $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
        $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
        $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
        $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
        $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
        if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) {
            $receiveType = 'all';
        }

        $rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType);
        $exportRows = [];
        foreach ($rows as $row) {
            $exportRows[] = [
                (string) ($row['display_date'] ?? ''),
                (string) ($row['bag_name'] ?? ''),
                (int) ($row['received_qty_sheet'] ?? 0),
                (string) ($row['order_date'] ?? ''),
                (int) ($row['order_qty_sheet'] ?? 0),
                (string) ($row['order_no'] ?? ''),
                (string) ($row['company_name'] ?? ''),
                (string) ($row['receive_status_label'] ?? ''),
                (string) ($row['agency_name'] ?? ''),
                '',
            ];
        }

        export_xlsx(
            '입고현황_' . date('Ymd'),
            '입고현황',
            ['입고일자', '품명', '입고수량', '발주일자', '발주수량', '발주번호', '제작업체', '입고여부', '입고처', '비고'],
            $exportRows
        );
    }

    /**
     * 미입고 잔량이 있는 발주 LOT 목록(스캐너 입고용 드롭다운).
     *
     * @return list<array{lot_no: string, bo_idx: int, order_date: string, company_name: string, pending_lines: int}>
     */
    private function buildReceivingPendingLotChoices(int $lgIdx, int $companyIdx = 0): array
    {
        $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '');
        $byLot = [];
        foreach ($rows as $r) {
            $lot = (string) ($r['lot_no'] ?? '');
            if ($lot === '') {
                continue;
            }
            if (! isset($byLot[$lot])) {
                $byLot[$lot] = [
                    'lot_no' => $lot,
                    'bo_idx' => (int) ($r['bo_idx'] ?? 0),
                    'order_date' => (string) ($r['order_date'] ?? ''),
                    'company_name' => (string) ($r['company_name'] ?? ''),
                    'pending_lines' => 0,
                ];
            }
            $byLot[$lot]['pending_lines']++;
        }
        $list = array_values($byLot);
        usort($list, static function (array $a, array $b): int {
            $da = (string) ($a['order_date'] ?? '');
            $db = (string) ($b['order_date'] ?? '');
            if ($da === $db) {
                return strcmp((string) ($b['lot_no'] ?? ''), (string) ($a['lot_no'] ?? ''));
            }

            return strcmp($db, $da);
        });

        return $list;
    }

    private function sanitizeLotNoForReceiving(int $lgIdx, int $companyIdx, string $lotNo): string
    {
        $lotNo = trim($lotNo);
        if ($lotNo === '') {
            return '';
        }
        foreach ($this->buildReceivingPendingLotChoices($lgIdx, $companyIdx) as $choice) {
            if ((string) ($choice['lot_no'] ?? '') === $lotNo) {
                return $lotNo;
            }
        }

        return '';
    }

    /**
     * 선택 조건(제작업체 + LOT)에 해당하는 미입고 품목(봉투) 목록 — 조회 조건 드롭다운용.
     *
     * @return list<array{bag_code: string, bag_name: string}>
     */
    private function receivingBagFilterOptions(int $lgIdx, int $companyIdx, string $lotNo = ''): array
    {
        if ($companyIdx <= 0) {
            return [];
        }
        $allForFilter = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, $lotNo);
        $byCode = [];
        foreach ($allForFilter as $r) {
            $c = (string) ($r['bag_code'] ?? '');
            if ($c === '') {
                continue;
            }
            if (! isset($byCode[$c])) {
                $byCode[$c] = (string) ($r['bag_name'] ?? '');
            }
        }
        $list = [];
        foreach ($byCode as $code => $name) {
            $list[] = ['bag_code' => $code, 'bag_name' => $name];
        }
        usort($list, static fn (array $a, array $b): int => strcmp($a['bag_name'], $b['bag_name']));

        return $list;
    }

    private function sanitizeBagCodeForReceiving(int $lgIdx, int $companyIdx, string $lotNo, string $bagCode): string
    {
        $bagCode = trim($bagCode);
        if ($bagCode === '' || $companyIdx <= 0) {
            return '';
        }
        foreach ($this->receivingBagFilterOptions($lgIdx, $companyIdx, $lotNo) as $opt) {
            if ($opt['bag_code'] === $bagCode) {
                return $bagCode;
            }
        }

        return '';
    }

    /**
     * 입고 대상 후보(LOT-봉투행) 생성.
     *
     * @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만.
     */
    private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array
    {
        $orderBuilder = model(BagOrderModel::class)
            ->where('bo_lg_idx', $lgIdx)
            ->whereLatestHead($lgIdx)
            ->where('bo_status', 'normal')
            ->orderBy('bo_order_date', 'DESC')
            ->orderBy('bo_idx', 'DESC');
        if ($lotNo !== '') {
            $orderBuilder->where('bo_lot_no', $lotNo);
        }
        if ($companyIdx > 0) {
            $orderBuilder->where('bo_company_idx', $companyIdx);
        }
        $orders = $orderBuilder->findAll();
        if (empty($orders)) {
            return [];
        }

        $orderIds = array_map(static fn($o) => (int) ($o->bo_idx ?? 0), $orders);
        $companyMap = [];
        foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) {
            $companyMap[(int) ($company->cp_idx ?? 0)] = [
                'name' => (string) ($company->cp_name ?? ''),
                'rep' => (string) ($company->cp_rep_name ?? ''),
            ];
        }
        $agencyMap = [];
        foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
            $agencyMap[(int) ($agency->sa_idx ?? 0)] = (string) ($agency->sa_name ?? '');
        }
        $unitMap = [];
        foreach (model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() as $unit) {
            $unitMap[(string) ($unit->pu_bag_code ?? '')] = [
                'pack_per_sheet' => (int) ($unit->pu_pack_per_sheet ?? 1),
                'total_per_box' => (int) ($unit->pu_total_per_box ?? 1),
            ];
        }

        $itemBuilder = model(BagOrderItemModel::class)->whereIn('boi_bo_idx', $orderIds);
        if ($bagCode !== '') {
            $itemBuilder->where('boi_bag_code', $bagCode);
        }
        $items = $itemBuilder->orderBy('boi_bo_idx', 'DESC')->orderBy('boi_idx', 'ASC')->findAll();

        $receivedRows = model(BagReceivingModel::class)
            ->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty_sheet, MAX(br_receive_date) as last_receive_date')
            ->where('br_lg_idx', $lgIdx)
            ->whereIn('br_bo_idx', $orderIds)
            ->groupBy('br_bo_idx, br_bag_code')
            ->findAll();
        $receivedMap = [];
        foreach ($receivedRows as $recv) {
            $receivedMap[(int) ($recv->br_bo_idx ?? 0) . '|' . (string) ($recv->br_bag_code ?? '')] = [
                'recv_qty_sheet' => (int) ($recv->recv_qty_sheet ?? 0),
                'last_receive_date' => (string) ($recv->last_receive_date ?? ''),
            ];
        }

        $orderMap = [];
        foreach ($orders as $order) {
            $orderMap[(int) ($order->bo_idx ?? 0)] = $order;
        }

        $rows = [];
        foreach ($items as $item) {
            $boIdx = (int) ($item->boi_bo_idx ?? 0);
            if (! isset($orderMap[$boIdx])) {
                continue;
            }
            $order = $orderMap[$boIdx];
            $itemBagCode = (string) ($item->boi_bag_code ?? '');
            $recv = $receivedMap[$boIdx . '|' . $itemBagCode] ?? ['recv_qty_sheet' => 0, 'last_receive_date' => ''];
            $orderQtySheet = (int) ($item->boi_qty_sheet ?? 0);
            $receivedQtySheet = min($orderQtySheet, (int) ($recv['recv_qty_sheet'] ?? 0));
            $pendingQtySheet = max(0, $orderQtySheet - $receivedQtySheet);
            if ($onlyPending && $pendingQtySheet <= 0) {
                continue;
            }

            $unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
            $companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => ''];

            $rows[] = [
                'row_key' => $boIdx . '|' . $itemBagCode,
                'bo_idx' => $boIdx,
                'order_no' => sprintf('%06d', $boIdx),
                'lot_no' => (string) ($order->bo_lot_no ?? ''),
                'order_date' => (string) ($order->bo_order_date ?? ''),
                'company_name' => (string) ($companyInfo['name'] ?? ''),
                'company_rep_name' => (string) ($companyInfo['rep'] ?? ''),
                'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
                'bag_code' => $itemBagCode,
                'bag_name' => (string) ($item->boi_bag_name ?? ''),
                'order_qty_sheet' => $orderQtySheet,
                'received_qty_sheet' => $receivedQtySheet,
                'pending_qty_sheet' => $pendingQtySheet,
                'pack_per_sheet' => max(1, (int) ($unit['pack_per_sheet'] ?? 1)),
                'total_per_box' => max(1, (int) ($unit['total_per_box'] ?? 1)),
                'last_receive_date' => (string) ($recv['last_receive_date'] ?? ''),
            ];
        }

        return $rows;
    }

    private function buildReceivingStatusRows(
        int $lgIdx,
        string $startDate,
        string $endDate,
        int $companyIdx,
        string $bagCode,
        string $receiveType
    ): array {
        $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');
        $filtered = [];
        foreach ($rows as $row) {
            $pendingQty = (int) ($row['pending_qty_sheet'] ?? 0);
            $isCompleted = $pendingQty <= 0;
            if ($receiveType === 'completed' && ! $isCompleted) {
                continue;
            }
            if ($receiveType === 'pending' && $isCompleted) {
                continue;
            }

            $displayDate = (string) ($row['last_receive_date'] ?? '');
            if ($displayDate === '') {
                $displayDate = (string) ($row['order_date'] ?? '');
            }

            if ($startDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) && $displayDate < $startDate) {
                continue;
            }
            if ($endDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) && $displayDate > $endDate) {
                continue;
            }

            $row['display_date'] = $displayDate;
            $row['receive_status_label'] = $isCompleted ? '완료' : '미완료';
            $filtered[] = $row;
        }

        usort($filtered, static function (array $a, array $b): int {
            $da = (string) ($a['display_date'] ?? '');
            $db = (string) ($b['display_date'] ?? '');
            if ($da === $db) {
                return strcmp((string) ($a['bag_name'] ?? ''), (string) ($b['bag_name'] ?? ''));
            }

            return strcmp($da, $db);
        });

        return $filtered;
    }

    // --- 판매 등록 ---
    public function saleCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
        $kind     = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        return $this->render('판매 등록', 'bag/create_bag_sale', compact('shops', 'bagCodes'));
    }

    public function saleStore()
    {
        $admin = new \App\Controllers\Admin\BagSale();
        $admin->initController($this->request, $this->response, service('logger'));
        $result = $admin->store();
        if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
            return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
        }
        return redirect()->to(site_url('bag/sales'))->with('success', '판매 등록되었습니다.');
    }

    /**
     * 지정판매소 판매 화면.
     */
    public function designatedShopSaleCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
        $orders = $this->phoneOpenOrdersWithItems($lgIdx);
        return $this->render('지정판매소 판매', 'bag/designated_shop_sale', compact('shops', 'orders'));
    }

    /**
     * [개발용] 주문 접수 리스트에서 선택한 판매소(ds_idx) 기준, 판매 스캔 가능 후보 바코드 목록(JSON).
     * — `bag_sale_scan_code` in_stock(해당 판매소) + 미수령 전화주문 품목의 `bag_receiving_pack_code` in_stock(팩 코드).
     */
    public function designatedShopDevSaleableBarcodes()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
        }
        $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0);
        if ($dsIdx <= 0) {
            return $this->response->setJSON(['ok' => false, 'message' => '판매소(ds_idx)가 필요합니다.']);
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $merged = [];
        $soIdxHint = (int) ($this->request->getGet('so_idx') ?? 0);

        $scanRows = $db->table('bag_sale_scan_code')
            ->select('bssc_code, bssc_bag_code, bssc_bag_name, bssc_unit, bssc_qty, bssc_state, bssc_so_idx')
            ->where('bssc_lg_idx', $lgIdx)
            ->where('bssc_ds_idx', $dsIdx)
            ->where('bssc_state', 'in_stock')
            ->orderBy('bssc_code', 'ASC')
            ->limit(250)
            ->get()
            ->getResultArray();
        foreach ($scanRows as $r) {
            $merged[] = [
                'source' => '스캔(in_stock)',
                'code' => (string) ($r['bssc_code'] ?? ''),
                'bag_code' => (string) ($r['bssc_bag_code'] ?? ''),
                'bag_name' => (string) ($r['bssc_bag_name'] ?? ''),
                'unit' => (string) ($r['bssc_unit'] ?? ''),
                'qty' => (int) ($r['bssc_qty'] ?? 0),
                'so_idx' => (int) ($r['bssc_so_idx'] ?? 0),
                'state' => (string) ($r['bssc_state'] ?? ''),
                'extra' => '',
            ];
        }

        $bagCodes = $this->phoneOrderBagCodesForDesignatedDev($lgIdx, $dsIdx, $soIdxHint);
        if ($bagCodes !== [] && $db->tableExists('bag_receiving_pack_code')) {
            // 봉투코드별 30건씩 가져와, 한 코드(예: 3L)에 in_stock 팩이 많을 때 다른 코드(예: 5L)가 누락되는 문제를 방지.
            $perBagLimit = 30;
            foreach ($bagCodes as $bagCode) {
                $brpcRows = $db->table('bag_receiving_pack_code')
                    ->select('brpc_pack_code, brpc_box_code, brpc_bag_code, brpc_bag_name, brpc_state, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
                    ->where('brpc_lg_idx', $lgIdx)
                    ->where('brpc_state', 'in_stock')
                    ->where('brpc_bag_code', $bagCode)
                    ->orderBy('brpc_pack_code', 'ASC')
                    ->limit($perBagLimit)
                    ->get()
                    ->getResultArray();
                foreach ($brpcRows as $r) {
                    $pack = (string) ($r['brpc_pack_code'] ?? '');
                    if ($pack === '') {
                        continue;
                    }
                    $from = (string) ($r['brpc_sheet_start_code'] ?? '');
                    $to = (string) ($r['brpc_sheet_end_code'] ?? '');
                    $extra = $from !== '' && $to !== '' ? ($from . ' ~ ' . $to) : '';
                    if (($r['brpc_box_code'] ?? '') !== '') {
                        $extra = trim($extra . ' / 박스:' . (string) ($r['brpc_box_code'] ?? ''));
                    }
                    $merged[] = [
                        'source' => '입고(in_stock)',
                        'code' => $pack,
                        'bag_code' => (string) ($r['brpc_bag_code'] ?? ''),
                        'bag_name' => (string) ($r['brpc_bag_name'] ?? ''),
                        'unit' => '팩(낱장수)',
                        'qty' => max(0, (int) ($r['brpc_sheet_qty'] ?? 0)),
                        'so_idx' => 0,
                        'state' => (string) ($r['brpc_state'] ?? ''),
                        'extra' => $extra,
                    ];
                }
            }
        }

        // 선택 주문 품목은 판매 내역에 보이지만, in_stock 입고팩·스캔재고가 없으면 위 루프에는 안 나온다(예: 5L만 미입고).
        if ($soIdxHint > 0) {
            $order = model(ShopOrderModel::class)
                ->where('so_idx', $soIdxHint)
                ->where('so_lg_idx', $lgIdx)
                ->where('so_status', 'normal')
                ->first();
            if ($order !== null) {
                $hasBagCode = static function (array $rows, string $bagCode): bool {
                    $want = trim($bagCode);
                    if ($want === '') {
                        return false;
                    }
                    foreach ($rows as $row) {
                        if (trim((string) ($row['bag_code'] ?? '')) === $want) {
                            return true;
                        }
                    }

                    return false;
                };
                $items = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdxHint)->findAll();
                foreach ($items as $it) {
                    $c = trim((string) ($it->soi_bag_code ?? ''));
                    $name = (string) ($it->soi_bag_name ?? '');
                    $qty = (int) ($it->soi_qty ?? 0);
                    if ($c !== '') {
                        if (! $hasBagCode($merged, $c)) {
                            $merged[] = [
                                'source' => '주문품목(재고미일치)',
                                'code' => '-',
                                'bag_code' => $c,
                                'bag_name' => $name,
                                'unit' => '주문수량',
                                'qty' => $qty,
                                'so_idx' => $soIdxHint,
                                'state' => '-',
                                'extra' => 'in_stock 입고팩·해당 판매소 스캔재고에 이 봉투코드가 없습니다. 입고 데이터·지자체·봉투코드 일치 여부를 확인하세요.',
                            ];
                        }
                    } elseif ($name !== '') {
                        $merged[] = [
                            'source' => '주문품목(봉투코드없음)',
                            'code' => '-',
                            'bag_code' => '',
                            'bag_name' => $name,
                            'unit' => '주문수량',
                            'qty' => $qty,
                            'so_idx' => $soIdxHint,
                            'state' => '-',
                            'extra' => 'shop_order_item.soi_bag_code 가 비어 있어 후보 팩을 조회할 수 없습니다.',
                        ];
                    }
                }
            }
        }

        return $this->response->setJSON(['ok' => true, 'rows' => $merged, 'ds_idx' => $dsIdx]);
    }

    /**
     * [개발용 임시] 판매관리 각 화면 하단에 표시할 "현재 전체 판매처리된 내역" JSON.
     * — `bag_sale_scan_code` 의 lg_idx 전체(상태 무관) 최근 N건을 일자 역순으로 반환.
     *   sold/in_stock(반품 복귀) 모두 포함되어 한눈에 흐름을 보기 위함.
     *   운영 배포 시 라우트·뷰 패널과 함께 제거 예정.
     */
    public function devAllSalesHistory()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();

        $session = session();
        $diag = [
            'mb_idx' => $session->get('mb_idx'),
            'mb_level' => $session->get('mb_level'),
            'mb_lg_idx' => $session->get('mb_lg_idx'),
            'admin_selected_lg_idx' => $session->get('admin_selected_lg_idx'),
        ];

        $resp = $this->response
            ->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
            ->setHeader('Pragma', 'no-cache')
            ->setHeader('Expires', '0');

        if (! $lgIdx) {
            return $resp->setJSON([
                'ok' => false,
                'message' => '작업 지자체가 선택되어 있지 않습니다. (Super Admin이면 /admin/select-local-government 에서 지자체를 선택해 주세요.)',
                'rows' => [], 'lg_idx' => null,
                'orders' => 0, 'sold' => 0, 'returned' => 0,
                'session' => $diag,
            ]);
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();

        $limit = (int) ($this->request->getGet('limit') ?? 500);
        if ($limit <= 0 || $limit > 2000) {
            $limit = 500;
        }

        // 1) 주문 접수(shop_order + shop_order_item) — 주문 1건 × 품목 N개로 펼침
        $events = [];
        $orderCount = 0;
        if ($db->tableExists('shop_order')) {
            $hasChannel = $db->fieldExists('so_channel', 'shop_order');
            $orderSelect = 'o.so_idx, o.so_ds_idx, o.so_regdate, o.so_status'
                . ($hasChannel ? ', o.so_channel' : '')
                . ', d.ds_shop_no, d.ds_name';
            $orderRows = $db->table('shop_order o')
                ->select($orderSelect)
                ->join('designated_shop d', 'd.ds_idx = o.so_ds_idx', 'left')
                ->where('o.so_lg_idx', $lgIdx)
                ->orderBy('o.so_regdate', 'DESC')
                ->orderBy('o.so_idx', 'DESC')
                ->limit($limit)
                ->get()
                ->getResultArray();
            $orderCount = (int) $db->table('shop_order')->where('so_lg_idx', $lgIdx)->countAllResults();

            $orderIds = array_values(array_filter(array_map(static fn ($r) => (int) ($r['so_idx'] ?? 0), $orderRows), static fn ($v) => $v > 0));
            $itemsByOrder = [];
            if ($orderIds !== [] && $db->tableExists('shop_order_item')) {
                $itemRows = $db->table('shop_order_item')
                    ->select('soi_so_idx, soi_bag_code, soi_bag_name, soi_qty')
                    ->whereIn('soi_so_idx', $orderIds)
                    ->get()
                    ->getResultArray();
                foreach ($itemRows as $it) {
                    $sid = (int) ($it['soi_so_idx'] ?? 0);
                    $itemsByOrder[$sid] = $itemsByOrder[$sid] ?? [];
                    $itemsByOrder[$sid][] = $it;
                }
            }

            foreach ($orderRows as $o) {
                $sid = (int) ($o['so_idx'] ?? 0);
                $items = $itemsByOrder[$sid] ?? [];
                if ($items === []) {
                    $events[] = [
                        'event_time' => $this->formatDevPanelEventTime($o['so_regdate'] ?? null),
                        'event_type' => 'order',
                        'so_status' => (string) ($o['so_status'] ?? ''),
                        'so_channel' => (string) ($o['so_channel'] ?? ''),
                        'ds_idx' => (int) ($o['so_ds_idx'] ?? 0),
                        'ds_shop_no' => (string) ($o['ds_shop_no'] ?? ''),
                        'ds_name' => (string) ($o['ds_name'] ?? ''),
                        'so_idx' => $sid,
                        'bag_code' => '',
                        'bag_name' => '',
                        'code' => '',
                        'unit' => '',
                        'qty' => 0,
                    ];
                    continue;
                }
                foreach ($items as $it) {
                    $events[] = [
                        'event_time' => $this->formatDevPanelEventTime($o['so_regdate'] ?? null),
                        'event_type' => 'order',
                        'so_status' => (string) ($o['so_status'] ?? ''),
                        'so_channel' => (string) ($o['so_channel'] ?? ''),
                        'ds_idx' => (int) ($o['so_ds_idx'] ?? 0),
                        'ds_shop_no' => (string) ($o['ds_shop_no'] ?? ''),
                        'ds_name' => (string) ($o['ds_name'] ?? ''),
                        'so_idx' => $sid,
                        'bag_code' => (string) ($it['soi_bag_code'] ?? ''),
                        'bag_name' => (string) ($it['soi_bag_name'] ?? ''),
                        'code' => '',
                        'unit' => '',
                        'qty' => (int) ($it['soi_qty'] ?? 0),
                    ];
                }
            }
        }

        // 2) 판매 처리 / 재고 복귀(bag_sale_scan_code)
        $soldCount = 0;
        $returnedCount = 0;
        if ($db->tableExists('bag_sale_scan_code')) {
            $scanRows = $db->table('bag_sale_scan_code b')
                ->select('b.bssc_ds_idx, d.ds_shop_no, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate')
                ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left')
                ->where('b.bssc_lg_idx', $lgIdx)
                ->orderBy('b.bssc_regdate', 'DESC')
                ->orderBy('b.bssc_idx', 'DESC')
                ->limit($limit)
                ->get()
                ->getResultArray();
            foreach ($scanRows as $s) {
                $state = strtolower((string) ($s['bssc_state'] ?? ''));
                $events[] = [
                    'event_time' => $this->formatDevPanelEventTime($s['bssc_regdate'] ?? null),
                    'event_type' => $state === 'in_stock' ? 'returned' : 'sale',
                    'so_status' => '',
                    'so_channel' => '',
                    'ds_idx' => (int) ($s['bssc_ds_idx'] ?? 0),
                    'ds_shop_no' => (string) ($s['ds_shop_no'] ?? ''),
                    'ds_name' => (string) ($s['ds_name'] ?? ''),
                    'so_idx' => (int) ($s['bssc_so_idx'] ?? 0),
                    'bag_code' => (string) ($s['bssc_bag_code'] ?? ''),
                    'bag_name' => (string) ($s['bssc_bag_name'] ?? ''),
                    'code' => (string) ($s['bssc_code'] ?? ''),
                    'unit' => (string) ($s['bssc_unit'] ?? ''),
                    'qty' => (int) ($s['bssc_qty'] ?? 0),
                ];
            }
            $soldCount = (int) $db->table('bag_sale_scan_code')->where('bssc_lg_idx', $lgIdx)->where('bssc_state', 'sold')->countAllResults();
            $returnedCount = (int) $db->table('bag_sale_scan_code')->where('bssc_lg_idx', $lgIdx)->where('bssc_state', 'in_stock')->countAllResults();
        }

        // 시간 역순 통합 정렬 후 limit
        usort($events, static function (array $a, array $b): int {
            return strcmp((string) ($b['event_time'] ?? ''), (string) ($a['event_time'] ?? ''));
        });
        if (count($events) > $limit) {
            $events = array_slice($events, 0, $limit);
        }

        return $resp->setJSON([
            'ok' => true,
            'rows' => $events,
            'limit' => $limit,
            'lg_idx' => $lgIdx,
            'orders' => $orderCount,
            'sold' => $soldCount,
            'returned' => $returnedCount,
            'session' => $diag,
        ]);
    }

    /**
     * 지정판매소 판매 화면 개발용 바코드 표: 해당 판매소의 전화주문(정상) 품목 봉투코드.
     * 주문 리스트와 동일하게 so_received 로 제한하지 않는다(완료 주문도 표시되므로).
     *
     * @return list<string>
     */
    private function phoneOrderBagCodesForDesignatedDev(int $lgIdx, int $dsIdx, int $soIdxHint): array
    {
        $db = \Config\Database::connect();
        $orderModel = model(ShopOrderModel::class)
            ->where('so_lg_idx', $lgIdx)
            ->where('so_ds_idx', $dsIdx)
            ->where('so_status', 'normal');
        if ($db->fieldExists('so_channel', 'shop_order')) {
            $orderModel->where('so_channel', 'phone');
        }
        $orders = $orderModel->findAll();
        $ids = array_values(array_filter(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders), static fn (int $v): bool => $v > 0));
        $codes = [];
        if ($ids !== []) {
            $items = model(ShopOrderItemModel::class)->whereIn('soi_so_idx', $ids)->findAll();
            foreach ($items as $it) {
                $c = trim((string) ($it->soi_bag_code ?? ''));
                if ($c !== '') {
                    $codes[$c] = true;
                }
            }
        }

        if ($soIdxHint > 0) {
            // so_ds_idx 는 요청 ds와 DB 불일치가 있어도 품목 봉투코드 병합은 수행(리스트·주문은 동일 LG 내에서 선택됨).
            $order = model(ShopOrderModel::class)
                ->where('so_idx', $soIdxHint)
                ->where('so_lg_idx', $lgIdx)
                ->where('so_status', 'normal')
                ->first();
            if ($order !== null) {
                $rows = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdxHint)->findAll();
                foreach ($rows as $it) {
                    $c = trim((string) ($it->soi_bag_code ?? ''));
                    if ($c !== '') {
                        $codes[$c] = true;
                    }
                }
            }
        }

        return array_keys($codes);
    }

    /**
     * 바코드 스캔 검증 API.
     */
    public function designatedShopSaleScan()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
        }

        $soIdx = (int) ($this->request->getPost('so_idx') ?? 0);
        $barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
        $pendingMapRaw = (string) ($this->request->getPost('pending_by_bag') ?? '{}');
        $pendingMap = json_decode($pendingMapRaw, true);
        if (! is_array($pendingMap)) {
            $pendingMap = [];
        }
        if ($soIdx <= 0 || $barcode === '') {
            return $this->response->setJSON(['ok' => false, 'message' => '주문 또는 바코드 값이 올바르지 않습니다.']);
        }

        $order = model(ShopOrderModel::class)->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
        if (! $order || (string) ($order->so_status ?? '') !== 'normal') {
            return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.']);
        }

        $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
        if (! $scan['ok']) {
            return $this->response->setJSON($scan);
        }

        $bagCode = (string) ($scan['bag_code'] ?? '');
        $orderItem = model(ShopOrderItemModel::class)
            ->where('soi_so_idx', $soIdx)
            ->where('soi_bag_code', $bagCode)
            ->first();
        if (! $orderItem) {
            return $this->response->setJSON(['ok' => false, 'message' => '선택 주문에 없는 봉투 종류입니다.']);
        }

        $soldRows = model(BagSaleModel::class)
            ->select('COALESCE(SUM(bs_qty), 0) AS sold_qty')
            ->where('bs_lg_idx', $lgIdx)
            ->where('bs_so_idx', $soIdx)
            ->where('bs_bag_code', $bagCode)
            // sale + cancel/return(bs_qty 음수로 저장) = 순판매량
            ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel'])
            ->first();
        $soldQty = (int) ($soldRows->sold_qty ?? 0);
        $receiptQty = (int) ($orderItem->soi_qty ?? 0);
        $pendingQty = max(0, (int) ($pendingMap[$bagCode] ?? 0));
        $remain = max(0, $receiptQty - $soldQty - $pendingQty);
        $scanQty = (int) ($scan['qty'] ?? 0);

        if ($scanQty <= 0) {
            return $this->response->setJSON(['ok' => false, 'message' => '수량 계산에 실패했습니다.']);
        }
        if ($scanQty > $remain) {
            return $this->response->setJSON([
                'ok' => false,
                'message' => '접수 잔량을 초과합니다. (잔량: ' . number_format($remain) . ')',
            ]);
        }

        return $this->response->setJSON(array_merge($scan, [
            'ok' => true,
            'remain' => $remain,
            'order_item_qty' => $receiptQty,
            'already_sold_qty' => $soldQty,
        ]));
    }

    /**
     * 지정판매소 판매 저장.
     */
    public function designatedShopSaleSave()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->back()->with('error', '지자체를 선택해 주세요.');
        }

        $soIdx = (int) ($this->request->getPost('so_idx') ?? 0);
        $dsIdx = (int) ($this->request->getPost('ds_idx') ?? 0);
        $scansJson = (string) ($this->request->getPost('scans_json') ?? '[]');
        $scans = json_decode($scansJson, true);
        if ($soIdx <= 0 || ! is_array($scans) || $scans === []) {
            return redirect()->back()->with('error', '저장할 판매 데이터가 없습니다.');
        }

        $orderModel = model(ShopOrderModel::class);
        $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
        if (! $order || (string) ($order->so_status ?? '') !== 'normal') {
            return redirect()->back()->with('error', '저장 가능한 주문이 아닙니다.');
        }
        if ($dsIdx <= 0) {
            $dsIdx = (int) ($order->so_ds_idx ?? 0);
        }

        $itemRows = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdx)->findAll();
        $itemMap = [];
        foreach ($itemRows as $it) {
            $itemMap[(string) ($it->soi_bag_code ?? '')] = $it;
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $db->transStart();

        $saleQtyByBag = [];
        $seenBarcode = [];
        $now = date('Y-m-d H:i:s');
        foreach ($scans as $scan) {
            if (! is_array($scan)) {
                continue;
            }
            $barcode = trim((string) ($scan['barcode'] ?? ''));
            $bagCode = trim((string) ($scan['bag_code'] ?? ''));
            $bagName = trim((string) ($scan['bag_name'] ?? ''));
            $unit = trim((string) ($scan['unit'] ?? ''));
            $qty = max(0, (int) ($scan['qty'] ?? 0));
            $packIds = $scan['pack_ids'] ?? [];
            if (! is_array($packIds)) {
                $packIds = [];
            }

            if ($barcode === '' || $bagCode === '' || $qty <= 0) {
                continue;
            }
            if (isset($seenBarcode[$barcode])) {
                $db->transRollback();
                return redirect()->back()->with('error', '동일 바코드가 중복되었습니다: ' . $barcode);
            }
            $seenBarcode[$barcode] = true;

            $exists = $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_code', $barcode)
                ->where('bssc_state', 'sold')
                ->countAllResults();
            if ($exists > 0) {
                $db->transRollback();
                return redirect()->back()->with('error', '이미 판매 처리된 바코드가 포함되어 있습니다: ' . $barcode);
            }
            if (! isset($itemMap[$bagCode])) {
                $db->transRollback();
                return redirect()->back()->with('error', '주문에 없는 봉투 종류가 포함되어 있습니다: ' . $bagCode);
            }

            $scanCodeRow = $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_code', $barcode)
                ->get()
                ->getRowArray();
            if (is_array($scanCodeRow)) {
                $db->table('bag_sale_scan_code')
                    ->where('bssc_idx', (int) ($scanCodeRow['bssc_idx'] ?? 0))
                    ->update([
                        'bssc_so_idx' => $soIdx,
                        'bssc_ds_idx' => $dsIdx,
                        'bssc_bag_code' => $bagCode,
                        'bssc_bag_name' => $bagName,
                        'bssc_unit' => $unit,
                        'bssc_qty' => $qty,
                        'bssc_state' => 'sold',
                        'bssc_regdate' => $now,
                    ]);
            } else {
                $db->table('bag_sale_scan_code')->insert([
                    'bssc_lg_idx' => $lgIdx,
                    'bssc_so_idx' => $soIdx,
                    'bssc_ds_idx' => $dsIdx,
                    'bssc_bag_code' => $bagCode,
                    'bssc_bag_name' => $bagName,
                    'bssc_code' => $barcode,
                    'bssc_unit' => $unit,
                    'bssc_qty' => $qty,
                    'bssc_state' => 'sold',
                    'bssc_regdate' => $now,
                ]);
            }

            // 팩/박스 판매일 때만 수신 팩 상태를 sold로 전환한다.
            // 낱장 판매는 동일 팩의 다른 낱장을 계속 판매해야 하므로 pack 상태를 유지한다.
            if ($packIds !== [] && $unit !== '낱장') {
                $db->table('bag_receiving_pack_code')
                    ->where('brpc_lg_idx', $lgIdx)
                    ->whereIn('brpc_idx', array_map(static fn ($v): int => (int) $v, $packIds))
                    ->set(['brpc_state' => 'sold'])
                    ->update();
            }

            $saleQtyByBag[$bagCode] = ($saleQtyByBag[$bagCode] ?? 0) + $qty;
        }

        foreach ($saleQtyByBag as $bagCode => $qty) {
            $bagCode = (string) $bagCode;
            $item = $itemMap[$bagCode] ?? null;
            if (! $item) {
                continue;
            }
            $unitPrice = (float) ($item->soi_unit_price ?? 0);
            model(BagSaleModel::class)->insert([
                'bs_lg_idx' => $lgIdx,
                'bs_so_idx' => $soIdx,
                'bs_ds_idx' => $dsIdx,
                'bs_ds_name' => (string) ($order->so_ds_name ?? ''),
                'bs_sale_date' => date('Y-m-d'),
                'bs_bag_code' => $bagCode,
                'bs_bag_name' => (string) ($item->soi_bag_name ?? ''),
                'bs_qty' => (int) $qty,
                'bs_unit_price' => $unitPrice,
                'bs_amount' => $unitPrice * (int) $qty,
                'bs_type' => 'sale',
                'bs_regdate' => $now,
            ]);
            model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, (string) ($item->soi_bag_name ?? ''), -((int) $qty));
        }

        $soldByBag = [];
        $soldRows = model(BagSaleModel::class)
            ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty')
            ->where('bs_lg_idx', $lgIdx)
            ->where('bs_so_idx', $soIdx)
            ->where('bs_type', 'sale')
            ->groupBy('bs_bag_code')
            ->findAll();
        foreach ($soldRows as $r) {
            $soldByBag[(string) ($r->bs_bag_code ?? '')] = (int) ($r->sold_qty ?? 0);
        }
        $allSold = true;
        foreach ($itemRows as $it) {
            $code = (string) ($it->soi_bag_code ?? '');
            $need = (int) ($it->soi_qty ?? 0);
            $sold = (int) ($soldByBag[$code] ?? 0);
            if ($sold < $need) {
                $allSold = false;
                break;
            }
        }
        if ($allSold) {
            $orderModel->update($soIdx, ['so_received' => 1]);
        }

        $db->transComplete();

        return redirect()->to(site_url('bag/sale/designated'))->with('success', '판매 저장이 완료되었습니다.');
    }

    /**
     * 지정판매소 반품 화면.
     */
    public function designatedShopSaleReturnCreate(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
        }

        $shops = model(DesignatedShopModel::class)
            ->where('ds_lg_idx', $lgIdx)
            ->where('ds_state', 1)
            ->orderBy('ds_name', 'ASC')
            ->findAll();

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $devSoldScans = $db->table('bag_sale_scan_code b')
            ->select('b.bssc_ds_idx, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate')
            ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left')
            ->where('b.bssc_lg_idx', $lgIdx)
            ->where('b.bssc_state', 'sold')
            ->orderBy('b.bssc_regdate', 'DESC')
            ->limit(200)
            ->get()
            ->getResultArray();

        return $this->render('지정판매소 반품', 'bag/designated_shop_sale_return', [
            'shops' => $shops,
            'devSoldScans' => $devSoldScans,
        ]);
    }

    /**
     * 지정판매소 반품 바코드 스캔 검증.
     */
    public function designatedShopSaleReturnScan()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
        }

        $barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
        if ($barcode === '') {
            return $this->response->setJSON(['ok' => false, 'message' => '바코드를 입력해 주세요.']);
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $scan = $db->table('bag_sale_scan_code b')
            ->select('b.bssc_so_idx, b.bssc_ds_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, d.ds_shop_no, d.ds_name, d.ds_rep_name, d.ds_tel, d.ds_addr, d.ds_addr_detail')
            ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left')
            ->where('b.bssc_lg_idx', $lgIdx)
            ->where('b.bssc_code', $barcode)
            ->where('b.bssc_state', 'sold')
            ->get()
            ->getRowArray();
        if (! is_array($scan)) {
            return $this->response->setJSON(['ok' => false, 'message' => '없는 바코드이거나 반품 가능한 판매코드가 아닙니다.']);
        }

        $dsIdx = (int) ($scan['bssc_ds_idx'] ?? 0);

        $soIdx = (int) ($scan['bssc_so_idx'] ?? 0);
        $bagCode = (string) ($scan['bssc_bag_code'] ?? '');
        $unitPrice = 0.0;
        if ($soIdx > 0 && $bagCode !== '') {
            $sale = model(BagSaleModel::class)
                ->where('bs_lg_idx', $lgIdx)
                ->where('bs_so_idx', $soIdx)
                ->where('bs_ds_idx', $dsIdx)
                ->where('bs_bag_code', $bagCode)
                ->where('bs_type', 'sale')
                ->orderBy('bs_idx', 'DESC')
                ->first();
            $unitPrice = (float) ($sale->bs_unit_price ?? 0);
        }

        $qty = max(0, (int) ($scan['bssc_qty'] ?? 0));
        $addr = trim((string) ($scan['ds_addr'] ?? '') . ' ' . (string) ($scan['ds_addr_detail'] ?? ''));

        return $this->response->setJSON([
            'ok' => true,
            'code' => (string) ($scan['bssc_code'] ?? ''),
            'bag_code' => $bagCode,
            'bag_name' => (string) ($scan['bssc_bag_name'] ?? ''),
            'qty' => $qty,
            'unit' => (string) ($scan['bssc_unit'] ?? ''),
            'unit_price' => $unitPrice,
            'amount' => $unitPrice * $qty,
            'so_idx' => $soIdx,
            'ds_idx' => $dsIdx,
            'ds_shop_no' => (string) ($scan['ds_shop_no'] ?? ''),
            'ds_name' => (string) ($scan['ds_name'] ?? ''),
            'ds_rep_name' => (string) ($scan['ds_rep_name'] ?? ''),
            'ds_tel' => (string) ($scan['ds_tel'] ?? ''),
            'ds_addr' => $addr,
        ]);
    }

    /**
     * 지정판매소 반품 저장.
     */
    public function designatedShopSaleReturnSave(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->back()->with('error', '지자체를 선택해 주세요.');
        }

        $dsIdx = (int) ($this->request->getPost('ds_idx') ?? 0);
        $scansJson = (string) ($this->request->getPost('scans_json') ?? '[]');
        $scans = json_decode($scansJson, true);
        if ($dsIdx <= 0 || ! is_array($scans) || $scans === []) {
            return redirect()->back()->with('error', '반품할 데이터가 없습니다.');
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $this->ensureDesignatedReturnScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $returnDate = date('Y-m-d');
        $codes = [];
        $scanMetaByCode = [];
        foreach ($scans as $row) {
            if (! is_array($row)) {
                continue;
            }
            $code = trim((string) ($row['code'] ?? ''));
            if ($code !== '') {
                $codes[] = $code;
                $scanMetaByCode[$code] = [
                    'unit' => trim((string) ($row['unit'] ?? '')),
                    'unit_price' => (float) ($row['unit_price'] ?? 0),
                ];
            }
        }
        $codes = array_values(array_unique($codes));
        if ($codes === []) {
            return redirect()->back()->with('error', '반품할 봉투 코드를 확인해 주세요.');
        }

        $scanRows = $db->table('bag_sale_scan_code')
            ->where('bssc_lg_idx', $lgIdx)
            ->where('bssc_ds_idx', $dsIdx)
            ->where('bssc_state', 'sold')
            ->whereIn('bssc_code', $codes)
            ->get()
            ->getResultArray();
        if ($scanRows === []) {
            return redirect()->back()->with('error', '반품 가능한 판매 코드가 없습니다.');
        }

        $db->transStart();

        $agg = [];
        $affectedOrderIds = [];
        $priceCache = [];
        foreach ($scanRows as $row) {
            $code = (string) ($row['bssc_code'] ?? '');
            $soIdx = (int) ($row['bssc_so_idx'] ?? 0);
            $bagCode = (string) ($row['bssc_bag_code'] ?? '');
            $bagName = (string) ($row['bssc_bag_name'] ?? '');
            $unit = (string) ($row['bssc_unit'] ?? '');
            $qty = max(0, (int) ($row['bssc_qty'] ?? 0));
            if ($code === '' || $bagCode === '' || $qty <= 0) {
                continue;
            }
            if ($soIdx > 0) {
                $affectedOrderIds[$soIdx] = true;
            }

            $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_code', $code)
                ->update(['bssc_state' => 'in_stock']);

            $this->restoreReceivingPackStateByCode($lgIdx, $code);

            $priceKey = $soIdx . '|' . $bagCode;
            if (! array_key_exists($priceKey, $priceCache)) {
                $latestSale = model(BagSaleModel::class)
                    ->where('bs_lg_idx', $lgIdx)
                    ->where('bs_so_idx', $soIdx)
                    ->where('bs_ds_idx', $dsIdx)
                    ->where('bs_bag_code', $bagCode)
                    ->where('bs_type', 'sale')
                    ->orderBy('bs_idx', 'DESC')
                    ->first();
                $priceCache[$priceKey] = (float) ($latestSale->bs_unit_price ?? 0);
            }
            $unitPrice = (float) ($scanMetaByCode[$code]['unit_price'] ?? $priceCache[$priceKey] ?? 0);
            $unit = (string) ($scanMetaByCode[$code]['unit'] ?? $unit);
            $db->table('bag_return_scan_code')->insert([
                'brsc_lg_idx' => $lgIdx,
                'brsc_so_idx' => $soIdx,
                'brsc_ds_idx' => $dsIdx,
                'brsc_bag_code' => $bagCode,
                'brsc_bag_name' => $bagName,
                'brsc_code' => $code,
                'brsc_unit' => $unit,
                'brsc_qty' => $qty,
                'brsc_unit_price' => $unitPrice,
                'brsc_amount' => $unitPrice * $qty,
                'brsc_return_date' => $returnDate,
                'brsc_state' => 'returned',
                'brsc_regdate' => date('Y-m-d H:i:s'),
            ]);

            $k = $soIdx . '|' . $bagCode;
            if (! isset($agg[$k])) {
                $agg[$k] = [
                    'so_idx' => $soIdx,
                    'bag_code' => $bagCode,
                    'bag_name' => $bagName,
                    'qty' => 0,
                ];
            }
            $agg[$k]['qty'] += $qty;
        }

        foreach ($agg as $entry) {
            $soIdx = (int) ($entry['so_idx'] ?? 0);
            $bagCode = (string) ($entry['bag_code'] ?? '');
            $bagName = (string) ($entry['bag_name'] ?? '');
            $qty = max(0, (int) ($entry['qty'] ?? 0));
            if ($qty <= 0 || $bagCode === '') {
                continue;
            }

            $sale = model(BagSaleModel::class)
                ->where('bs_lg_idx', $lgIdx)
                ->where('bs_so_idx', $soIdx)
                ->where('bs_ds_idx', $dsIdx)
                ->where('bs_bag_code', $bagCode)
                ->where('bs_type', 'sale')
                ->orderBy('bs_idx', 'DESC')
                ->first();
            $unitPrice = (float) ($sale->bs_unit_price ?? 0);

            model(BagSaleModel::class)->insert([
                'bs_lg_idx' => $lgIdx,
                'bs_so_idx' => $soIdx,
                'bs_ds_idx' => $dsIdx,
                'bs_ds_name' => (string) ($sale->bs_ds_name ?? ''),
                'bs_sale_date' => date('Y-m-d'),
                'bs_bag_code' => $bagCode,
                'bs_bag_name' => $bagName,
                'bs_qty' => -$qty,
                'bs_unit_price' => $unitPrice,
                'bs_amount' => $unitPrice * $qty,
                'bs_type' => 'return',
                'bs_regdate' => date('Y-m-d H:i:s'),
            ]);
            model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, $qty);
        }

        $this->recalculateOrderReceivedStatus($lgIdx, array_keys($affectedOrderIds));

        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->with('error', '반품 저장 중 오류가 발생했습니다.');
        }

        return redirect()->to(site_url('bag/sale/designated-return'))->with('success', '반품 저장이 완료되었습니다.');
    }

    /**
     * 지정판매소 반품 취소 화면.
     */
    public function designatedShopSaleReturnCancelCreate(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
        }

        $returnDate = trim((string) ($this->request->getGet('return_date') ?? date('Y-m-d')));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $returnDate)) {
            $returnDate = date('Y-m-d');
        }

        $this->ensureDesignatedReturnScanCodeTable($lgIdx);
        $db = \Config\Database::connect();

        $shopRows = $db->table('bag_return_scan_code r')
            ->select('r.brsc_ds_idx, d.ds_name, d.ds_rep_name, r.brsc_return_date')
            ->join('designated_shop d', 'd.ds_idx = r.brsc_ds_idx', 'left')
            ->where('r.brsc_lg_idx', $lgIdx)
            ->where('r.brsc_return_date', $returnDate)
            ->where('r.brsc_state', 'returned')
            ->groupBy('r.brsc_ds_idx, d.ds_name, d.ds_rep_name, r.brsc_return_date')
            ->orderBy('d.ds_name', 'ASC')
            ->get()
            ->getResultArray();

        $returnRows = $db->table('bag_return_scan_code')
            ->select('brsc_idx, brsc_so_idx, brsc_ds_idx, brsc_bag_code, brsc_bag_name, brsc_code, brsc_unit, brsc_qty, brsc_unit_price, brsc_amount, brsc_return_date')
            ->where('brsc_lg_idx', $lgIdx)
            ->where('brsc_return_date', $returnDate)
            ->where('brsc_state', 'returned')
            ->orderBy('brsc_ds_idx', 'ASC')
            ->orderBy('brsc_bag_code', 'ASC')
            ->orderBy('brsc_code', 'ASC')
            ->get()
            ->getResultArray();

        $payload = [];
        foreach ($returnRows as $row) {
            $payload[] = [
                'so_idx' => (int) ($row['brsc_so_idx'] ?? 0),
                'ds_idx' => (int) ($row['brsc_ds_idx'] ?? 0),
                'bag_code' => (string) ($row['brsc_bag_code'] ?? ''),
                'bag_name' => (string) ($row['brsc_bag_name'] ?? ''),
                'code' => (string) ($row['brsc_code'] ?? ''),
                'unit' => (string) ($row['brsc_unit'] ?? ''),
                'qty' => (int) ($row['brsc_qty'] ?? 0),
                'unit_price' => (float) ($row['brsc_unit_price'] ?? 0),
                'amount' => (float) ($row['brsc_amount'] ?? 0),
                'return_date' => (string) ($row['brsc_return_date'] ?? ''),
            ];
        }

        return $this->render('지정판매소 반품 취소', 'bag/designated_shop_return_cancel', [
            'returnDate' => $returnDate,
            'shops' => $shopRows,
            'returns' => $payload,
        ]);
    }

    /**
     * 지정판매소 반품 취소 저장.
     */
    public function designatedShopSaleReturnCancelSave(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->back()->with('error', '지자체를 선택해 주세요.');
        }

        $returnDate = trim((string) ($this->request->getPost('return_date') ?? ''));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $returnDate)) {
            return redirect()->back()->with('error', '반품 일자를 확인해 주세요.');
        }

        $selectedJson = (string) ($this->request->getPost('selected_codes_json') ?? '[]');
        $selectedCodes = json_decode($selectedJson, true);
        if (! is_array($selectedCodes) || $selectedCodes === []) {
            return redirect()->back()->with('error', '반품 취소할 봉투 코드를 선택해 주세요.');
        }
        $selectedCodes = array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes)));
        $selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== ''));
        if ($selectedCodes === []) {
            return redirect()->back()->with('error', '반품 취소할 봉투 코드를 선택해 주세요.');
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $this->ensureDesignatedReturnScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $rows = $db->table('bag_return_scan_code')
            ->where('brsc_lg_idx', $lgIdx)
            ->where('brsc_return_date', $returnDate)
            ->where('brsc_state', 'returned')
            ->whereIn('brsc_code', $selectedCodes)
            ->get()
            ->getResultArray();
        if ($rows === []) {
            return redirect()->back()->with('error', '반품 취소 가능한 봉투 코드가 없습니다.');
        }

        $db->transStart();
        $agg = [];
        $affectedOrderIds = [];
        foreach ($rows as $row) {
            $code = (string) ($row['brsc_code'] ?? '');
            $soIdx = (int) ($row['brsc_so_idx'] ?? 0);
            $dsIdx = (int) ($row['brsc_ds_idx'] ?? 0);
            $bagCode = (string) ($row['brsc_bag_code'] ?? '');
            $bagName = (string) ($row['brsc_bag_name'] ?? '');
            $unit = (string) ($row['brsc_unit'] ?? '');
            $qty = max(0, (int) ($row['brsc_qty'] ?? 0));
            $unitPrice = (float) ($row['brsc_unit_price'] ?? 0);
            if ($code === '' || $bagCode === '' || $qty <= 0) {
                continue;
            }
            if ($soIdx > 0) {
                $affectedOrderIds[$soIdx] = true;
            }

            $db->table('bag_return_scan_code')
                ->where('brsc_idx', (int) ($row['brsc_idx'] ?? 0))
                ->set(['brsc_state' => 'cancelled'])
                ->update();

            $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_code', $code)
                ->set(['bssc_state' => 'sold'])
                ->update();

            if ($unit !== '낱장') {
                $db->table('bag_receiving_pack_code')
                    ->where('brpc_lg_idx', $lgIdx)
                    ->groupStart()
                        ->where('brpc_pack_code', $code)
                        ->orWhere('brpc_box_code', $code)
                    ->groupEnd()
                    ->set(['brpc_state' => 'sold'])
                    ->update();
            }

            $k = $soIdx . '|' . $dsIdx . '|' . $bagCode;
            if (! isset($agg[$k])) {
                $agg[$k] = [
                    'so_idx' => $soIdx,
                    'ds_idx' => $dsIdx,
                    'bag_code' => $bagCode,
                    'bag_name' => $bagName,
                    'qty' => 0,
                    'unit_price' => $unitPrice,
                ];
            }
            $agg[$k]['qty'] += $qty;
            if ($agg[$k]['unit_price'] <= 0 && $unitPrice > 0) {
                $agg[$k]['unit_price'] = $unitPrice;
            }
        }

        foreach ($agg as $entry) {
            $soIdx = (int) ($entry['so_idx'] ?? 0);
            $dsIdx = (int) ($entry['ds_idx'] ?? 0);
            $bagCode = (string) ($entry['bag_code'] ?? '');
            $bagName = (string) ($entry['bag_name'] ?? '');
            $qty = max(0, (int) ($entry['qty'] ?? 0));
            if ($qty <= 0 || $bagCode === '') {
                continue;
            }

            $order = model(ShopOrderModel::class)
                ->where('so_lg_idx', $lgIdx)
                ->where('so_idx', $soIdx)
                ->first();
            $unitPrice = (float) ($entry['unit_price'] ?? 0);
            if ($unitPrice <= 0) {
                $sale = model(BagSaleModel::class)
                    ->where('bs_lg_idx', $lgIdx)
                    ->where('bs_so_idx', $soIdx)
                    ->where('bs_ds_idx', $dsIdx)
                    ->where('bs_bag_code', $bagCode)
                    ->where('bs_type', 'sale')
                    ->orderBy('bs_idx', 'DESC')
                    ->first();
                $unitPrice = (float) ($sale->bs_unit_price ?? 0);
            }

            model(BagSaleModel::class)->insert([
                'bs_lg_idx' => $lgIdx,
                'bs_so_idx' => $soIdx,
                'bs_ds_idx' => $dsIdx,
                'bs_ds_name' => (string) ($order->so_ds_name ?? ''),
                'bs_sale_date' => date('Y-m-d'),
                'bs_bag_code' => $bagCode,
                'bs_bag_name' => $bagName,
                'bs_qty' => $qty,
                'bs_unit_price' => $unitPrice,
                'bs_amount' => $unitPrice * $qty,
                'bs_type' => 'return_cancel',
                'bs_regdate' => date('Y-m-d H:i:s'),
            ]);
            model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, -$qty);
        }

        $this->recalculateOrderReceivedStatus($lgIdx, array_keys($affectedOrderIds));

        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->with('error', '반품 취소 저장 중 오류가 발생했습니다.');
        }

        return redirect()->to(site_url('bag/sale/designated-return-cancel?return_date=' . rawurlencode($returnDate)))
            ->with('success', '반품 취소 저장이 완료되었습니다.');
    }

    /**
     * 지정판매소 판매 취소 화면.
     */
    public function designatedShopReturnCreate(): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
        }

        $saleDate = trim((string) ($this->request->getGet('sale_date') ?? date('Y-m-d')));
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $saleDate)) {
            $saleDate = date('Y-m-d');
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();

        $shopRows = $db->table('bag_sale_scan_code b')
            ->select('b.bssc_ds_idx, d.ds_name')
            ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left')
            ->where('b.bssc_lg_idx', $lgIdx)
            ->where('b.bssc_state', 'sold')
            ->where('DATE(b.bssc_regdate)', $saleDate)
            ->groupBy('b.bssc_ds_idx, d.ds_name')
            ->orderBy('d.ds_name', 'ASC')
            ->get()
            ->getResultArray();

        $scanRows = $db->table('bag_sale_scan_code b')
            ->select('bssc_idx, bssc_so_idx, bssc_ds_idx, bssc_bag_code, bssc_bag_name, bssc_code, bssc_unit, bssc_qty, bssc_regdate')
            ->where('bssc_lg_idx', $lgIdx)
            ->where('bssc_state', 'sold')
            ->where('DATE(bssc_regdate)', $saleDate)
            ->orderBy('bssc_ds_idx', 'ASC')
            ->orderBy('bssc_bag_code', 'ASC')
            ->orderBy('bssc_code', 'ASC')
            ->get()
            ->getResultArray();

        $saleRows = model(BagSaleModel::class)
            ->select('bs_so_idx, bs_ds_idx, bs_bag_code, bs_unit_price')
            ->where('bs_lg_idx', $lgIdx)
            ->where('bs_type', 'sale')
            ->where('bs_sale_date', $saleDate)
            ->findAll();
        $unitPriceMap = [];
        foreach ($saleRows as $row) {
            $k = (int) ($row->bs_so_idx ?? 0) . '|' . (int) ($row->bs_ds_idx ?? 0) . '|' . (string) ($row->bs_bag_code ?? '');
            if (! isset($unitPriceMap[$k])) {
                $unitPriceMap[$k] = (float) ($row->bs_unit_price ?? 0);
            }
        }

        $salePayload = [];
        foreach ($scanRows as $row) {
            $soIdx = (int) ($row['bssc_so_idx'] ?? 0);
            $dsIdx = (int) ($row['bssc_ds_idx'] ?? 0);
            $bagCode = (string) ($row['bssc_bag_code'] ?? '');
            $priceKey = $soIdx . '|' . $dsIdx . '|' . $bagCode;
            $unitPrice = (float) ($unitPriceMap[$priceKey] ?? 0);
            $qty = (int) ($row['bssc_qty'] ?? 0);
            $salePayload[] = [
                'bssc_idx' => (int) ($row['bssc_idx'] ?? 0),
                'so_idx' => $soIdx,
                'ds_idx' => $dsIdx,
                'bag_code' => $bagCode,
                'bag_name' => (string) ($row['bssc_bag_name'] ?? ''),
                'code' => (string) ($row['bssc_code'] ?? ''),
                'unit' => (string) ($row['bssc_unit'] ?? ''),
                'qty' => $qty,
                'unit_price' => $unitPrice,
                'amount' => $unitPrice * $qty,
                'regdate' => (string) ($row['bssc_regdate'] ?? ''),
            ];
        }

        $devSoldScans = $db->table('bag_sale_scan_code b')
            ->select('b.bssc_ds_idx, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate')
            ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left')
            ->where('b.bssc_lg_idx', $lgIdx)
            ->where('b.bssc_state', 'sold')
            ->where('DATE(b.bssc_regdate)', $saleDate)
            ->orderBy('d.ds_name', 'ASC')
            ->orderBy('b.bssc_code', 'ASC')
            ->limit(500)
            ->get()
            ->getResultArray();

        return $this->render('지정판매소 판매 취소', 'bag/designated_shop_return', [
            'saleDate' => $saleDate,
            'shops' => $shopRows,
            'sales' => $salePayload,
            'canCancel' => ($saleDate === date('Y-m-d')),
            'devSoldScans' => $devSoldScans,
        ]);
    }

    /**
     * 지정판매소 판매 취소 처리.
     */
    public function designatedShopReturnCancel(): RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->back()->with('error', '지자체를 선택해 주세요.');
        }

        $saleDate = trim((string) ($this->request->getPost('sale_date') ?? ''));
        if ($saleDate !== date('Y-m-d')) {
            return redirect()->back()->with('error', '과거 판매일자는 취소 처리할 수 없습니다.');
        }

        $selectedJson = (string) ($this->request->getPost('selected_codes_json') ?? '[]');
        $selectedCodes = json_decode($selectedJson, true);
        if (! is_array($selectedCodes) || $selectedCodes === []) {
            return redirect()->back()->with('error', '취소할 봉투 코드를 선택해 주세요.');
        }

        $selectedCodes = array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes)));
        $selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== ''));
        if ($selectedCodes === []) {
            return redirect()->back()->with('error', '취소할 봉투 코드를 선택해 주세요.');
        }

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $rows = $db->table('bag_sale_scan_code')
            ->where('bssc_lg_idx', $lgIdx)
            ->where('bssc_state', 'sold')
            ->where('DATE(bssc_regdate)', $saleDate)
            ->whereIn('bssc_code', $selectedCodes)
            ->get()
            ->getResultArray();
        if ($rows === []) {
            return redirect()->back()->with('error', '취소 가능한 판매 봉투 코드가 없습니다.');
        }

        $db->transStart();

        $agg = [];
        $affectedOrderIds = [];
        foreach ($rows as $row) {
            $code = (string) ($row['bssc_code'] ?? '');
            $bagCode = (string) ($row['bssc_bag_code'] ?? '');
            $bagName = (string) ($row['bssc_bag_name'] ?? '');
            $soIdx = (int) ($row['bssc_so_idx'] ?? 0);
            $dsIdx = (int) ($row['bssc_ds_idx'] ?? 0);
            $qty = max(0, (int) ($row['bssc_qty'] ?? 0));
            $affectedOrderIds[$soIdx] = true;

            $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_code', $code)
                ->set(['bssc_state' => 'in_stock'])
                ->update();

            // 취소된 코드가 다시 판매 가능하도록 수신 팩 상태도 in_stock으로 복원한다.
            // - 팩코드 취소: 해당 팩 복원
            // - 박스코드 취소: 해당 박스 전체 팩 복원
            // - 낱장코드 취소: 해당 낱장이 속한 팩 복원
            $this->restoreReceivingPackStateByCode($lgIdx, $code);

            $k = $soIdx . '|' . $dsIdx . '|' . $bagCode;
            if (! isset($agg[$k])) {
                $agg[$k] = [
                    'so_idx' => $soIdx,
                    'ds_idx' => $dsIdx,
                    'bag_code' => $bagCode,
                    'bag_name' => $bagName,
                    'qty' => 0,
                ];
            }
            $agg[$k]['qty'] += $qty;
        }

        $orders = model(ShopOrderModel::class)
            ->where('so_lg_idx', $lgIdx)
            ->whereIn('so_idx', array_keys($affectedOrderIds))
            ->findAll();
        $orderMap = [];
        foreach ($orders as $o) {
            $orderMap[(int) ($o->so_idx ?? 0)] = $o;
        }

        foreach ($agg as $entry) {
            $soIdx = (int) ($entry['so_idx'] ?? 0);
            $dsIdx = (int) ($entry['ds_idx'] ?? 0);
            $bagCode = (string) ($entry['bag_code'] ?? '');
            $bagName = (string) ($entry['bag_name'] ?? '');
            $qty = max(0, (int) ($entry['qty'] ?? 0));
            if ($qty <= 0) {
                continue;
            }

            $sale = model(BagSaleModel::class)
                ->where('bs_lg_idx', $lgIdx)
                ->where('bs_so_idx', $soIdx)
                ->where('bs_ds_idx', $dsIdx)
                ->where('bs_bag_code', $bagCode)
                ->where('bs_type', 'sale')
                ->orderBy('bs_idx', 'DESC')
                ->first();
            $unitPrice = (float) ($sale->bs_unit_price ?? 0);

            model(BagSaleModel::class)->insert([
                'bs_lg_idx' => $lgIdx,
                'bs_so_idx' => $soIdx,
                'bs_ds_idx' => $dsIdx,
                'bs_ds_name' => (string) (($orderMap[$soIdx]->so_ds_name ?? '') ?: ''),
                'bs_sale_date' => $saleDate,
                'bs_bag_code' => $bagCode,
                'bs_bag_name' => $bagName,
                'bs_qty' => -$qty,
                'bs_unit_price' => $unitPrice,
                'bs_amount' => $unitPrice * $qty,
                'bs_type' => 'cancel',
                'bs_regdate' => date('Y-m-d H:i:s'),
            ]);
            model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, $qty);
        }

        foreach (array_keys($affectedOrderIds) as $soIdx) {
            $remainSold = $db->table('bag_sale_scan_code')
                ->where('bssc_lg_idx', $lgIdx)
                ->where('bssc_so_idx', (int) $soIdx)
                ->where('bssc_state', 'sold')
                ->countAllResults();
            if ($remainSold <= 0) {
                model(ShopOrderModel::class)->update((int) $soIdx, ['so_received' => 0]);
            }
        }

        $db->transComplete();
        if (! $db->transStatus()) {
            return redirect()->back()->with('error', '판매 취소 처리 중 오류가 발생했습니다.');
        }

        return redirect()->to(site_url('bag/sale/designated-cancel?sale_date=' . rawurlencode($saleDate)))
            ->with('success', '선택한 품목이 취소 처리되었습니다.');
    }

    /**
     * 미배달(미수령) 전화 주문 목록 + 품목/판매 누계.
     */
    private function phoneOpenOrdersWithItems(?int $lgIdx): array
    {
        if (! $lgIdx) {
            return [];
        }
        $db = \Config\Database::connect();
        $orderModel = model(ShopOrderModel::class);
        $builder = $orderModel
            ->where('so_lg_idx', $lgIdx)
            ->where('so_status', 'normal');
        if ($db->fieldExists('so_channel', 'shop_order')) {
            $builder->where('so_channel', 'phone');
        }
        $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll();
        if ($orders === []) {
            return [];
        }

        $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders));
        $itemRows = model(ShopOrderItemModel::class)
            ->whereIn('soi_so_idx', $orderIds)
            ->orderBy('soi_so_idx', 'ASC')
            ->orderBy('soi_idx', 'ASC')
            ->findAll();

        $soldRows = model(BagSaleModel::class)
            ->select('bs_so_idx, bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty')
            ->where('bs_lg_idx', $lgIdx)
            ->whereIn('bs_so_idx', $orderIds)
            // sale + cancel/return(bs_qty sign 반영) = 순판매량
            // (cancel/return은 통상 음수(bs_qty)로 저장되어 순판매량이 되도록 함)
            ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel'])
            ->groupBy('bs_so_idx, bs_bag_code')
            ->findAll();
        $soldMap = [];
        foreach ($soldRows as $row) {
            $k = (int) ($row->bs_so_idx ?? 0) . '|' . (string) ($row->bs_bag_code ?? '');
            $soldMap[$k] = (int) ($row->sold_qty ?? 0);
        }

        $itemsByOrder = [];
        foreach ($itemRows as $it) {
            $orderId = (int) ($it->soi_so_idx ?? 0);
            $code = (string) ($it->soi_bag_code ?? '');
            $sold = (int) ($soldMap[$orderId . '|' . $code] ?? 0);
            $qty = (int) ($it->soi_qty ?? 0);
            $amount = (float) ($it->soi_amount ?? 0);
            $itemsByOrder[$orderId][] = [
                'soi_idx' => (int) ($it->soi_idx ?? 0),
                'soi_bag_code' => $code,
                'soi_bag_name' => (string) ($it->soi_bag_name ?? ''),
                'soi_qty' => $qty,
                'soi_unit_price' => (float) ($it->soi_unit_price ?? 0),
                'soi_amount' => $amount,
                'sold_qty' => $sold,
                'remain_qty' => max(0, $qty - $sold),
            ];
        }

        $payload = [];
        foreach ($orders as $o) {
            $id = (int) ($o->so_idx ?? 0);
            $payload[] = [
                'so_idx' => $id,
                'so_ds_idx' => (int) ($o->so_ds_idx ?? 0),
                'so_ds_name' => (string) ($o->so_ds_name ?? ''),
                'so_order_date' => (string) ($o->so_order_date ?? ''),
                'so_delivery_date' => (string) ($o->so_delivery_date ?? ''),
                'so_payment_type' => (string) ($o->so_payment_type ?? ''),
                'so_status' => (string) ($o->so_status ?? 'normal'),
                'so_received' => (int) ($o->so_received ?? 0),
                'items' => $itemsByOrder[$id] ?? [],
            ];
        }

        return $payload;
    }

    /**
     * 바코드 해석(박스/팩/낱장).
     */
    private function resolveDesignatedSaleBarcode(int $lgIdx, string $barcode): array
    {
        $barcode = trim($barcode);
        if ($barcode === '') {
            return ['ok' => false, 'message' => '바코드를 입력해 주세요.'];
        }

        $db = \Config\Database::connect();
        $this->ensureDesignatedSaleScanCodeTable($lgIdx);

        $already = $db->table('bag_sale_scan_code')
            ->where('bssc_lg_idx', $lgIdx)
            ->where('bssc_code', $barcode)
            ->where('bssc_state', 'sold')
            ->countAllResults();
        if ($already > 0) {
            return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.'];
        }

        $pack = $db->table('bag_receiving_pack_code')
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_pack_code', $barcode)
            ->get()
            ->getRow();
        if ($pack) {
            if ((string) ($pack->brpc_state ?? '') !== 'in_stock') {
                return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.'];
            }
            return [
                'ok' => true,
                'barcode' => $barcode,
                'unit' => '팩',
                'bag_code' => (string) ($pack->brpc_bag_code ?? ''),
                'bag_name' => (string) ($pack->brpc_bag_name ?? ''),
                'qty' => max(0, (int) ($pack->brpc_sheet_qty ?? 0)),
                'pack_ids' => [(int) ($pack->brpc_idx ?? 0)],
            ];
        }

        $boxRows = $db->table('bag_receiving_pack_code')
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_box_code', $barcode)
            ->get()
            ->getResult();
        if ($boxRows !== []) {
            $first = $boxRows[0];
            $inStock = array_values(array_filter($boxRows, static fn ($r): bool => (string) ($r->brpc_state ?? '') === 'in_stock'));
            if ($inStock === []) {
                return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.'];
            }
            $qty = 0;
            $packIds = [];
            foreach ($inStock as $row) {
                $qty += max(0, (int) ($row->brpc_sheet_qty ?? 0));
                $packIds[] = (int) ($row->brpc_idx ?? 0);
            }
            return [
                'ok' => true,
                'barcode' => $barcode,
                'unit' => '박스',
                'bag_code' => (string) ($first->brpc_bag_code ?? ''),
                'bag_name' => (string) ($first->brpc_bag_name ?? ''),
                'qty' => $qty,
                'pack_ids' => $packIds,
            ];
        }

        $sheetRows = $db->table('bag_receiving_pack_code')
            ->where('brpc_lg_idx', $lgIdx)
            ->where('brpc_sheet_start_code <=', $barcode)
            ->where('brpc_sheet_end_code >=', $barcode)
            ->limit(200)
            ->get()
            ->getResult();
        foreach ($sheetRows as $row) {
            $start = (string) ($row->brpc_sheet_start_code ?? '');
            $end = (string) ($row->brpc_sheet_end_code ?? '');
            if (! $this->barcodeInRange($barcode, $start, $end)) {
                continue;
            }
            if ((string) ($row->brpc_state ?? '') !== 'in_stock') {
                $scan = $db->table('bag_sale_scan_code')
                    ->select('bssc_state')
                    ->where('bssc_lg_idx', $lgIdx)
                    ->where('bssc_code', $barcode)
                    ->get()
                    ->getRow();
                // 낱장 코드가 취소로 in_stock이면 수신 팩 상태를 자동 복구한다.
                if ((string) ($scan->bssc_state ?? '') === 'in_stock') {
                    $db->table('bag_receiving_pack_code')
                        ->where('brpc_lg_idx', $lgIdx)
                        ->where('brpc_idx', (int) ($row->brpc_idx ?? 0))
                        ->set(['brpc_state' => 'in_stock'])
                        ->update();
                } else {
                    return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.'];
                }
            }
            return [
                'ok' => true,
                'barcode' => $barcode,
                'unit' => '낱장',
                'bag_code' => (string) ($row->brpc_bag_code ?? ''),
                'bag_name' => (string) ($row->brpc_bag_name ?? ''),
                'qty' => 1,
                'pack_ids' => [(int) ($row->brpc_idx ?? 0)],
            ];
        }

        return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.'];
    }

    private function barcodeInRange(string $code, string $start, string $end): bool
    {
        if ($start === '' || $end === '') {
            return false;
        }

        $extract = static function (string $v): array {
            if (preg_match('/^(.*?)(\d+)$/', $v, $m) === 1) {
                return [(string) $m[1], (int) $m[2], strlen((string) $m[2])];
            }
            return ['', -1, 0];
        };
        [$cp, $cn, $cl] = $extract($code);
        [$sp, $sn, $sl] = $extract($start);
        [$ep, $en, $el] = $extract($end);
        if ($cn >= 0 && $sn >= 0 && $en >= 0 && $cp === $sp && $sp === $ep && $cl === $sl && $sl === $el) {
            return $cn >= $sn && $cn <= $en;
        }

        return strcmp($code, $start) >= 0 && strcmp($code, $end) <= 0;
    }

    private function restoreReceivingPackStateByCode(int $lgIdx, string $code): void
    {
        $db = \Config\Database::connect();
        $builder = $db->table('bag_receiving_pack_code')
            ->select('brpc_idx')
            ->where('brpc_lg_idx', $lgIdx)
            ->groupStart()
                ->where('brpc_pack_code', $code)
                ->orWhere('brpc_box_code', $code)
                ->orGroupStart()
                    ->where('brpc_sheet_start_code <=', $code)
                    ->where('brpc_sheet_end_code >=', $code)
                ->groupEnd()
            ->groupEnd();

        $rows = $builder->get()->getResultArray();
        if ($rows === [] && preg_match('/^(.*-P\\d+)-S\\d+$/', $code, $m) === 1) {
            $rows = $db->table('bag_receiving_pack_code')
                ->select('brpc_idx')
                ->where('brpc_lg_idx', $lgIdx)
                ->where('brpc_pack_code', (string) $m[1])
                ->get()
                ->getResultArray();
        }
        if ($rows === []) {
            return;
        }

        $packIds = array_values(array_filter(array_map(static fn ($r): int => (int) ($r['brpc_idx'] ?? 0), $rows), static fn ($v): bool => $v > 0));
        if ($packIds === []) {
            return;
        }

        $db->table('bag_receiving_pack_code')
            ->where('brpc_lg_idx', $lgIdx)
            ->whereIn('brpc_idx', $packIds)
            ->set(['brpc_state' => 'in_stock'])
            ->update();
    }

    /**
     * 지정 주문들의 수령완료 상태를 순판매량 기준으로 재계산한다.
     *
     * @param list<int|string> $orderIds
     */
    private function recalculateOrderReceivedStatus(int $lgIdx, array $orderIds): void
    {
        $orderIds = array_values(array_filter(array_map(static fn ($v): int => (int) $v, $orderIds), static fn ($v): bool => $v > 0));
        if ($orderIds === []) {
            return;
        }

        foreach ($orderIds as $soIdx) {
            $itemRows = model(ShopOrderItemModel::class)
                ->where('soi_so_idx', $soIdx)
                ->findAll();
            if ($itemRows === []) {
                continue;
            }

            $soldRows = model(BagSaleModel::class)
                ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty')
                ->where('bs_lg_idx', $lgIdx)
                ->where('bs_so_idx', $soIdx)
                ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel'])
                ->groupBy('bs_bag_code')
                ->findAll();
            $soldMap = [];
            foreach ($soldRows as $row) {
                $soldMap[(string) ($row->bs_bag_code ?? '')] = (int) ($row->sold_qty ?? 0);
            }

            $allSold = true;
            foreach ($itemRows as $it) {
                $code = (string) ($it->soi_bag_code ?? '');
                $need = (int) ($it->soi_qty ?? 0);
                $sold = (int) ($soldMap[$code] ?? 0);
                if ($sold < $need) {
                    $allSold = false;
                    break;
                }
            }

            model(ShopOrderModel::class)->update($soIdx, ['so_received' => $allSold ? 1 : 0]);
        }
    }

    private function ensureDesignatedSaleScanCodeTable(int $lgIdx): void
    {
        $db = \Config\Database::connect();
        if ($db->tableExists('bag_sale_scan_code')) {
            return;
        }
        $sql = <<<SQL
CREATE TABLE IF NOT EXISTS `bag_sale_scan_code` (
  `bssc_idx` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `bssc_lg_idx` INT UNSIGNED NOT NULL,
  `bssc_so_idx` INT UNSIGNED NOT NULL,
  `bssc_ds_idx` INT UNSIGNED NOT NULL,
  `bssc_bag_code` VARCHAR(50) NOT NULL DEFAULT '',
  `bssc_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
  `bssc_code` VARCHAR(120) NOT NULL,
  `bssc_unit` VARCHAR(10) NOT NULL DEFAULT '',
  `bssc_qty` INT UNSIGNED NOT NULL DEFAULT 0,
  `bssc_state` VARCHAR(20) NOT NULL DEFAULT 'sold',
  `bssc_regdate` DATETIME NOT NULL,
  PRIMARY KEY (`bssc_idx`),
  UNIQUE KEY `uk_bssc_lg_code` (`bssc_lg_idx`, `bssc_code`),
  KEY `idx_bssc_so_idx` (`bssc_so_idx`),
  KEY `idx_bssc_ds_idx` (`bssc_ds_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='지정판매소 판매 스캔 코드';
SQL;
        $db->query($sql);
    }

    private function ensureDesignatedReturnScanCodeTable(int $lgIdx): void
    {
        $db = \Config\Database::connect();
        if ($db->tableExists('bag_return_scan_code')) {
            return;
        }
        $sql = <<<SQL
CREATE TABLE IF NOT EXISTS `bag_return_scan_code` (
  `brsc_idx` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `brsc_lg_idx` INT UNSIGNED NOT NULL,
  `brsc_so_idx` INT UNSIGNED NOT NULL,
  `brsc_ds_idx` INT UNSIGNED NOT NULL,
  `brsc_bag_code` VARCHAR(50) NOT NULL DEFAULT '',
  `brsc_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
  `brsc_code` VARCHAR(120) NOT NULL,
  `brsc_unit` VARCHAR(10) NOT NULL DEFAULT '',
  `brsc_qty` INT UNSIGNED NOT NULL DEFAULT 0,
  `brsc_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
  `brsc_amount` DECIMAL(14,2) NOT NULL DEFAULT 0.00,
  `brsc_return_date` DATE NOT NULL,
  `brsc_state` VARCHAR(20) NOT NULL DEFAULT 'returned',
  `brsc_regdate` DATETIME NOT NULL,
  PRIMARY KEY (`brsc_idx`),
  KEY `idx_brsc_return_date` (`brsc_return_date`),
  KEY `idx_brsc_ds_idx` (`brsc_ds_idx`),
  KEY `idx_brsc_code` (`brsc_code`),
  KEY `idx_brsc_state` (`brsc_state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='지정판매소 반품 스캔 코드';
SQL;
        $db->query($sql);
    }

    // --- 주문 접수 ---
    public function shopOrderCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
        $kind     = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        return $this->render('주문 접수', 'bag/create_shop_order', compact('shops', 'bagCodes'));
    }

    /**
     * 전화 주문 접수 전용 화면.
     */
    public function phoneOrderCreate(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];

        $kind     = model(CodeKindModel::class)->where('ck_code', 'O')->first();
        $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
        $priceMap = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx);

        $unitRows = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->findAll();
        $unitMap = [];
        foreach ($unitRows as $unit) {
            $code = (string) ($unit->pu_bag_code ?? '');
            if ($code === '' || isset($unitMap[$code])) {
                continue;
            }
            $unitMap[$code] = $unit;
        }

        // 접수번호 — 매일 1번부터 시작(당일 접수 건수 + 1)
        $receiptNo = 1;
        if ($lgIdx) {
            $receiptNo = (int) model(ShopOrderModel::class)
                ->where('so_lg_idx', $lgIdx)
                ->where('so_order_date', date('Y-m-d'))
                ->countAllResults() + 1;
        }

        return $this->render('전화 주문 접수', 'bag/order_phone', compact('shops', 'bagCodes', 'priceMap', 'unitMap', 'receiptNo'));
    }

    /**
     * 전화 주문 접수 관리 화면(리스트/상세 수정/취소).
     */
    public function phoneOrderManage(): string
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
        }

        // 일자별·기간별 조회 (접수일 so_order_date 기준) — 파라미터 없이 진입하면 기본값 = 오늘
        $startRaw  = $this->request->getGet('start_date');
        $endRaw    = $this->request->getGet('end_date');
        $startDate = trim((string) ($startRaw ?? ''));
        $endDate   = trim((string) ($endRaw ?? ''));
        if ($startRaw === null && $endRaw === null) {
            $startDate = date('Y-m-d');
            $endDate   = date('Y-m-d');
        }
        $isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
        if ($isYmd($startDate) && $isYmd($endDate) && $startDate > $endDate) {
            [$startDate, $endDate] = [$endDate, $startDate];
        }

        $db = \Config\Database::connect();
        $orderModel = model(ShopOrderModel::class);
        $builder = $orderModel->where('so_lg_idx', $lgIdx);
        if ($db->fieldExists('so_channel', 'shop_order')) {
            $builder->where('so_channel', 'phone');
        }
        if ($isYmd($startDate)) {
            $builder->where('so_order_date >=', $startDate);
        }
        if ($isYmd($endDate)) {
            $builder->where('so_order_date <=', $endDate);
        }
        $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll();

        $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders));
        $itemRows = [];
        if ($orderIds !== []) {
            $itemRows = model(ShopOrderItemModel::class)
                ->whereIn('soi_so_idx', $orderIds)
                ->orderBy('soi_so_idx', 'ASC')
                ->orderBy('soi_idx', 'ASC')
                ->findAll();
        }

        $unitRows = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->findAll();
        $unitMap = [];
        foreach ($unitRows as $u) {
            $code = (string) ($u->pu_bag_code ?? '');
            if ($code === '' || isset($unitMap[$code])) {
                continue;
            }
            $unitMap[$code] = [
                'boxSheets' => (int) ($u->pu_total_per_box ?? 0),
                'packSheets' => (int) ($u->pu_pack_per_sheet ?? 0),
            ];
        }

        // 주문의 지정판매소 상세(코드·담당자·전화·주소) — 우측 '지정판매소 정보' 표시용
        $shopMap = [];
        $dsIdxs  = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
        if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
            $shopRows = $db->table('designated_shop')
                ->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
                ->whereIn('ds_idx', $dsIdxs)
                ->get()->getResultArray();
            foreach ($shopRows as $s) {
                $shopMap[(int) $s['ds_idx']] = [
                    'shop_no'  => (string) ($s['ds_shop_no'] ?? ''),
                    'name'     => (string) ($s['ds_name'] ?? ''),
                    'rep_name' => (string) ($s['ds_rep_name'] ?? ''),
                    'tel'      => (string) ($s['ds_tel'] ?? ''),
                    'addr'     => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
                ];
            }
        }

        // 스캔(=포장·판매)된 봉투코드 내역 — 포장 명세 표시 + 품목별 포장량(soi_packed_qty) 집계에 사용
        $scansByOrder     = [];
        $packedByOrderBag = [];
        if ($orderIds !== [] && $db->tableExists('bag_sale_scan_code')) {
            $scanRows = $db->table('bag_sale_scan_code')
                ->select('bssc_so_idx, bssc_bag_code, bssc_bag_name, bssc_code, bssc_unit, bssc_qty')
                ->where('bssc_lg_idx', $lgIdx)
                ->whereIn('bssc_so_idx', $orderIds)
                ->where('bssc_state', 'sold')
                ->orderBy('bssc_idx', 'ASC')
                ->get()->getResultArray();
            foreach ($scanRows as $s) {
                $oid  = (int) ($s['bssc_so_idx'] ?? 0);
                $code = (string) ($s['bssc_bag_code'] ?? '');
                $qty  = (int) ($s['bssc_qty'] ?? 0);
                $scansByOrder[$oid][] = [
                    'bag_code' => $code,
                    'bag_name' => (string) ($s['bssc_bag_name'] ?? ''),
                    'code'     => (string) ($s['bssc_code'] ?? ''),
                    'unit'     => (string) ($s['bssc_unit'] ?? ''),
                    'qty'      => $qty,
                ];
                $packedByOrderBag[$oid][$code] = ($packedByOrderBag[$oid][$code] ?? 0) + $qty;
            }
        }

        $itemsByOrder = [];
        foreach ($itemRows as $item) {
            $orderId = (int) ($item->soi_so_idx ?? 0);
            $code = (string) ($item->soi_bag_code ?? '');
            $pack = $unitMap[$code] ?? ['boxSheets' => 0, 'packSheets' => 0];
            // 포장량 = 스캔 집계 우선(포장 명세와 일치), 없으면 DB 저장값
            $packedQty = $packedByOrderBag[$orderId][$code] ?? (int) ($item->soi_packed_qty ?? 0);
            $itemsByOrder[$orderId][] = [
                'soi_idx' => (int) ($item->soi_idx ?? 0),
                'soi_bag_code' => $code,
                'soi_bag_name' => (string) ($item->soi_bag_name ?? ''),
                'soi_unit_price' => (int) ($item->soi_unit_price ?? 0),
                'soi_qty' => (int) ($item->soi_qty ?? 0),
                'soi_packed_qty' => (int) $packedQty,
                'soi_amount' => (int) ($item->soi_amount ?? 0),
                'soi_box_count' => (int) ($item->soi_box_count ?? 0),
                'soi_pack_count' => (int) ($item->soi_pack_count ?? 0),
                'soi_sheet_count' => (int) ($item->soi_sheet_count ?? 0),
                'box_sheets' => (int) ($pack['boxSheets'] ?? 0),
                'pack_sheets' => (int) ($pack['packSheets'] ?? 0),
            ];
        }

        $payload = [];
        foreach ($orders as $order) {
            $id = (int) ($order->so_idx ?? 0);
            $dsIdx = (int) ($order->so_ds_idx ?? 0);
            $shop  = $shopMap[$dsIdx] ?? ['shop_no' => '', 'name' => '', 'rep_name' => '', 'tel' => '', 'addr' => ''];
            $payload[] = [
                'so_idx' => $id,
                'so_ds_idx' => $dsIdx,
                'so_ds_name' => (string) ($order->so_ds_name ?? ''),
                'ds_shop_no' => $shop['shop_no'],
                'ds_rep_name' => $shop['rep_name'],
                'ds_tel' => $shop['tel'],
                'ds_addr' => $shop['addr'],
                'so_order_date' => (string) ($order->so_order_date ?? ''),
                'so_delivery_date' => (string) ($order->so_delivery_date ?? ''),
                'so_channel' => (string) ($order->so_channel ?? ''),
                'so_payment_type' => (string) ($order->so_payment_type ?? ''),
                'so_received' => (int) ($order->so_received ?? 0),
                'so_paid' => (int) ($order->so_paid ?? 0),
                'so_total_qty' => (int) ($order->so_total_qty ?? 0),
                'so_total_amount' => (int) ($order->so_total_amount ?? 0),
                'so_status' => (string) ($order->so_status ?? 'normal'),
                'items' => $itemsByOrder[$id] ?? [],
                'scans' => $scansByOrder[$id] ?? [],
            ];
        }

        return $this->render('주문접수 관리', 'bag/order_phone_manage', [
            'orders'    => $payload,
            'startDate' => $isYmd($startDate) ? $startDate : '',
            'endDate'   => $isYmd($endDate) ? $endDate : '',
        ]);
    }

    /**
     * 입금자 영수증 출력용 — 단일 접수건의 인쇄용 영수증(독립 HTML, 자동 인쇄).
     */
    public function phoneOrderReceipt(int $id): string|RedirectResponse
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.');
        }

        $order = model(ShopOrderModel::class)->where('so_idx', $id)->where('so_lg_idx', $lgIdx)->first();
        if (! $order) {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        $items = model(ShopOrderItemModel::class)
            ->where('soi_so_idx', $id)
            ->orderBy('soi_idx', 'ASC')
            ->findAll();

        $shop = null;
        $dsIdx = (int) ($order->so_ds_idx ?? 0);
        if ($dsIdx > 0) {
            $shop = \Config\Database::connect()->table('designated_shop')
                ->select('ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
                ->where('ds_idx', $dsIdx)
                ->get()->getRowArray();
        }

        $lgRow   = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
        $lgName  = $lgRow ? (string) $lgRow->lg_name : '';

        return view('bag/order_phone_receipt', [
            'order'  => $order,
            'items'  => $items,
            'shop'   => $shop,
            'lgName' => $lgName,
        ]);
    }

    /**
     * 전화 주문 상세 품목 수량 수정 저장.
     */
    public function phoneOrderUpdate()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.');
        }

        $soIdx = (int) $this->request->getPost('so_idx');
        if ($soIdx <= 0) {
            return redirect()->back()->with('error', '주문번호가 올바르지 않습니다.');
        }

        $orderModel = model(ShopOrderModel::class);
        $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
        if (! $order) {
            return redirect()->back()->with('error', '주문을 찾을 수 없습니다.');
        }
        if ((string) ($order->so_status ?? '') === 'cancelled') {
            return redirect()->back()->with('error', '취소된 주문은 수정할 수 없습니다.');
        }

        $itemModel = model(ShopOrderItemModel::class);
        $items = $itemModel->where('soi_so_idx', $soIdx)->findAll();
        if ($items === []) {
            return redirect()->back()->with('error', '주문 품목이 없습니다.');
        }

        $qtyInput = $this->request->getPost('item_qty') ?? [];
        if (! is_array($qtyInput)) {
            $qtyInput = [];
        }
        $packedInput = $this->request->getPost('item_packed') ?? [];
        if (! is_array($packedInput)) {
            $packedInput = [];
        }

        $codeSet = [];
        foreach ($items as $item) {
            $code = (string) ($item->soi_bag_code ?? '');
            if ($code !== '') {
                $codeSet[$code] = true;
            }
        }

        $unitRows = model(PackagingUnitModel::class)
            ->where('pu_lg_idx', $lgIdx)
            ->where('pu_state', 1)
            ->whereIn('pu_bag_code', array_keys($codeSet))
            ->findAll();
        $unitMap = [];
        foreach ($unitRows as $u) {
            $code = (string) ($u->pu_bag_code ?? '');
            if ($code === '' || isset($unitMap[$code])) {
                continue;
            }
            $unitMap[$code] = $u;
        }

        $db = \Config\Database::connect();
        $db->transStart();

        $sumQty = 0;
        $sumAmt = 0;
        foreach ($items as $item) {
            $itemId = (int) ($item->soi_idx ?? 0);
            $code = (string) ($item->soi_bag_code ?? '');
            $qty = isset($qtyInput[$itemId]) ? max(0, (int) $qtyInput[$itemId]) : (int) ($item->soi_qty ?? 0);
            $unitPrice = (int) ($item->soi_unit_price ?? 0);
            $amount = $qty * $unitPrice;

            $boxCount = 0;
            $packCount = 0;
            $sheetCount = $qty;
            $unit = $unitMap[$code] ?? null;
            if ($unit && (int) ($unit->pu_total_per_box ?? 0) > 0) {
                $boxSheets = (int) $unit->pu_total_per_box;
                $boxCount = intdiv($qty, $boxSheets);
                $remain = $qty % $boxSheets;
                $packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
                if ($packSheets > 0) {
                    $packCount = intdiv($remain, $packSheets);
                    $sheetCount = $remain % $packSheets;
                } else {
                    $sheetCount = $remain;
                }
            } elseif ($unit && (int) ($unit->pu_pack_per_sheet ?? 0) > 0) {
                $packSheets = (int) $unit->pu_pack_per_sheet;
                $packCount = intdiv($qty, $packSheets);
                $sheetCount = $qty % $packSheets;
            }

            $packedQty = isset($packedInput[$itemId]) ? max(0, (int) $packedInput[$itemId]) : (int) ($item->soi_packed_qty ?? 0);
            $itemModel->update($itemId, [
                'soi_qty' => $qty,
                'soi_packed_qty' => $packedQty,
                'soi_amount' => $amount,
                'soi_box_count' => $boxCount,
                'soi_pack_count' => $packCount,
                'soi_sheet_count' => $sheetCount,
            ]);

            $sumQty += $qty;
            $sumAmt += $amount;
        }

        $orderUpdate = [
            'so_total_qty' => $sumQty,
            'so_total_amount' => $sumAmt,
        ];
        // 배달일 수정 — 입력값이 유효한 날짜면 변경 반영(수정일자로 변경 처리)
        // 단, 접수일 이전 날짜로는 변경 불가.
        $deliveryDate = trim((string) ($this->request->getPost('so_delivery_date') ?? ''));
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate) === 1) {
            $orderDate = substr((string) ($order->so_order_date ?? ''), 0, 10);
            if ($orderDate !== '' && $deliveryDate < $orderDate) {
                $db->transRollback();

                return redirect()->back()->with('error', '배달일은 접수일(' . $orderDate . ') 이후로만 변경할 수 있습니다.');
            }
            $orderUpdate['so_delivery_date'] = $deliveryDate;
        }
        $orderModel->update($soIdx, $orderUpdate);

        $db->transComplete();

        return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.');
    }

    /**
     * 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답).
     * 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
     * 해당 품목의 포장량(soi_packed_qty) 반영. 잔량 한도는 접수량(soi_qty).
     */
    public function phoneOrderManageScan()
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
        }

        $soIdx   = (int) ($this->request->getPost('so_idx') ?? 0);
        $barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
        if ($soIdx <= 0 || $barcode === '') {
            return $this->response->setJSON(['ok' => false, 'message' => '주문 또는 바코드 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]);
        }

        $orderModel = model(ShopOrderModel::class);
        $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
        if (! $order || (string) ($order->so_status ?? '') !== 'normal') {
            return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]);
        }

        $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
        if (! ($scan['ok'] ?? false)) {
            return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));
        }

        $bagCode = (string) ($scan['bag_code'] ?? '');
        $orderItem = model(ShopOrderItemModel::class)
            ->where('soi_so_idx', $soIdx)
            ->where('soi_bag_code', $bagCode)
            ->first();
        if (! $orderItem) {
            return $this->response->setJSON(['ok' => false, 'message' => '선택 주문에 없는 봉투 종류입니다.', 'csrf' => csrf_hash()]);
        }

        // 이미 판매(=포장)된 순수량
        $soldRow = model(BagSaleModel::class)
            ->select('COALESCE(SUM(bs_qty),0) AS sold_qty')
            ->where('bs_lg_idx', $lgIdx)
            ->where('bs_so_idx', $soIdx)
            ->where('bs_bag_code', $bagCode)
            ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel'])
            ->first();
        $soldQty    = (int) ($soldRow->sold_qty ?? 0);
        $receiptQty = (int) ($orderItem->soi_qty ?? 0);
        $scanQty    = (int) ($scan['qty'] ?? 0);
        $remain     = max(0, $receiptQty - $soldQty);
        if ($scanQty <= 0) {
            return $this->response->setJSON(['ok' => false, 'message' => '수량 계산에 실패했습니다.', 'csrf' => csrf_hash()]);
        }
        if ($scanQty > $remain) {
            return $this->response->setJSON(['ok' => false, 'message' => '접수 잔량을 초과합니다. (잔량: ' . number_format($remain) . ')', 'csrf' => csrf_hash()]);
        }

        $dsIdx   = (int) ($order->so_ds_idx ?? 0);
        $bagName = (string) (($scan['bag_name'] ?? '') !== '' ? $scan['bag_name'] : ($orderItem->soi_bag_name ?? ''));
        $unit    = (string) ($scan['unit'] ?? '');
        $packIds = is_array($scan['pack_ids'] ?? null) ? $scan['pack_ids'] : [];
        $now     = date('Y-m-d H:i:s');

        $this->ensureDesignatedSaleScanCodeTable($lgIdx);
        $db = \Config\Database::connect();
        $db->transStart();

        // 스캔 코드 기록(sold)
        $scanCodeRow = $db->table('bag_sale_scan_code')
            ->where('bssc_lg_idx', $lgIdx)->where('bssc_code', $barcode)->get()->getRowArray();
        if (is_array($scanCodeRow)) {
            $db->table('bag_sale_scan_code')->where('bssc_idx', (int) ($scanCodeRow['bssc_idx'] ?? 0))->update([
                'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx, 'bssc_bag_code' => $bagCode,
                'bssc_bag_name' => $bagName, 'bssc_unit' => $unit, 'bssc_qty' => $scanQty,
                'bssc_state' => 'sold', 'bssc_regdate' => $now,
            ]);
        } else {
            $db->table('bag_sale_scan_code')->insert([
                'bssc_lg_idx' => $lgIdx, 'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx,
                'bssc_bag_code' => $bagCode, 'bssc_bag_name' => $bagName, 'bssc_code' => $barcode,
                'bssc_unit' => $unit, 'bssc_qty' => $scanQty, 'bssc_state' => 'sold', 'bssc_regdate' => $now,
            ]);
        }

        // 팩/박스는 수신 팩 상태를 sold로 전환(낱장은 유지)
        if ($packIds !== [] && $unit !== '낱장') {
            $db->table('bag_receiving_pack_code')
                ->where('brpc_lg_idx', $lgIdx)
                ->whereIn('brpc_idx', array_map(static fn ($v): int => (int) $v, $packIds))
                ->set(['brpc_state' => 'sold'])->update();
        }

        // 판매 기록 + 재고 차감
        $unitPrice = (float) ($orderItem->soi_unit_price ?? 0);
        model(BagSaleModel::class)->insert([
            'bs_lg_idx' => $lgIdx, 'bs_so_idx' => $soIdx, 'bs_ds_idx' => $dsIdx,
            'bs_ds_name' => (string) ($order->so_ds_name ?? ''), 'bs_sale_date' => date('Y-m-d'),
            'bs_bag_code' => $bagCode, 'bs_bag_name' => $bagName, 'bs_qty' => $scanQty,
            'bs_unit_price' => $unitPrice, 'bs_amount' => $unitPrice * $scanQty,
            'bs_type' => 'sale', 'bs_regdate' => $now,
        ]);
        model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$scanQty);

        // 포장량(soi_packed_qty) 반영
        $newPacked = $soldQty + $scanQty;
        model(ShopOrderItemModel::class)->update((int) ($orderItem->soi_idx ?? 0), ['soi_packed_qty' => $newPacked]);

        // 전량 판매 시 so_received=1
        $soldByBag = [];
        $sumRows = model(BagSaleModel::class)
            ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty')
            ->where('bs_lg_idx', $lgIdx)->where('bs_so_idx', $soIdx)->where('bs_type', 'sale')
            ->groupBy('bs_bag_code')->findAll();
        foreach ($sumRows as $r) {
            $soldByBag[(string) ($r->bs_bag_code ?? '')] = (int) ($r->sold_qty ?? 0);
        }
        $allItems = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdx)->findAll();
        $allSold = true;
        foreach ($allItems as $it) {
            if ((int) ($soldByBag[(string) ($it->soi_bag_code ?? '')] ?? 0) < (int) ($it->soi_qty ?? 0)) {
                $allSold = false;
                break;
            }
        }
        $orderModel->update($soIdx, ['so_received' => $allSold ? 1 : 0]);

        $db->transComplete();
        if ($db->transStatus() === false) {
            return $this->response->setJSON(['ok' => false, 'message' => '판매 처리 중 오류가 발생했습니다.', 'csrf' => csrf_hash()]);
        }

        return $this->response->setJSON([
            'ok'          => true,
            'csrf'        => csrf_hash(),
            'so_idx'      => $soIdx,
            'soi_idx'     => (int) ($orderItem->soi_idx ?? 0),
            'bag_code'    => $bagCode,
            'bag_name'    => $bagName,
            'code'        => $barcode,
            'unit'        => $unit,
            'qty'         => $scanQty,
            'packed_qty'  => $newPacked,
            'so_received' => $allSold ? 1 : 0,
            'remain'      => max(0, $receiptQty - $newPacked),
        ]);
    }

    /**
     * 전화 주문 취소(삭제가 아닌 상태값 변경).
     */
    public function phoneOrderCancel(int $id)
    {
        helper('admin');
        $lgIdx = $this->lgIdx();
        if (! $lgIdx) {
            return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.');
        }

        $orderModel = model(ShopOrderModel::class);
        $order = $orderModel->where('so_idx', $id)->where('so_lg_idx', $lgIdx)->first();
        if (! $order) {
            return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '주문을 찾을 수 없습니다.');
        }

        $orderModel->update($id, ['so_status' => 'cancelled']);

        return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문이 취소 처리되었습니다.');
    }

    public function shopOrderStore()
    {
        $admin = new \App\Controllers\Admin\ShopOrder();
        $admin->initController($this->request, $this->response, service('logger'));
        $result = $admin->store();

        // 호출 화면에서 hidden 'return_to' 로 돌아갈 경로 지정 가능. 화이트리스트로만 허용.
        $returnTo = trim((string) ($this->request->getPost('return_to') ?? ''));
        $allowed = ['bag/sales', 'bag/order/phone', 'bag/shop-order/create'];
        $target = in_array($returnTo, $allowed, true) ? $returnTo : 'bag/sales';

        if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
            return redirect()->to(site_url($target))
                ->with('success', session()->getFlashdata('success'))
                ->with('error', session()->getFlashdata('error'))
                ->with('errors', session()->getFlashdata('errors'));
        }
        return redirect()->to(site_url($target))->with('success', '주문 접수되었습니다.');
    }
}
