From c708d3066093808aa50e0917e349098285c56848 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Wed, 29 Apr 2026 14:59:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=82=AC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=EC=99=80=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EB=90=98=EA=B2=8C=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사 저장값이 페이지 이동 후 원복되지 않도록 저장/조회 경로를 보강하고, 코드 범위 보정과 bis 간 동기화를 추가했다. 또한 메뉴 관리를 레벨4 이상으로 제한하고 메뉴 변경 사항을 모든 지자체에 일괄 반영하도록 동기화 로직을 도입했다. Made-with: Cursor --- app/Controllers/Admin/Menu.php | 49 + app/Controllers/Bag.php | 2970 ++++++++++++++++- app/Models/MenuModel.php | 63 + app/Views/bag/inventory_inspection_select.php | 396 +++ .../inventory_inspection_select_overview.php | 273 ++ 5 files changed, 3730 insertions(+), 21 deletions(-) create mode 100644 app/Views/bag/inventory_inspection_select.php create mode 100644 app/Views/bag/inventory_inspection_select_overview.php diff --git a/app/Controllers/Admin/Menu.php b/app/Controllers/Admin/Menu.php index de426c1..f1b177e 100644 --- a/app/Controllers/Admin/Menu.php +++ b/app/Controllers/Admin/Menu.php @@ -23,6 +23,9 @@ class Menu extends BaseController */ public function index() { + if ($deny = $this->denyUnlessLevel4Plus()) { + return $deny; + } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { @@ -93,6 +96,9 @@ class Menu extends BaseController */ public function list() { + if ($deny = $this->denyUnlessLevel4Plus(true)) { + return $deny; + } $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']); @@ -112,6 +118,9 @@ class Menu extends BaseController */ public function store() { + if ($deny = $this->denyUnlessLevel4Plus()) { + return $deny; + } $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { return redirect()->to(base_url('admin/select-local-government')) @@ -144,6 +153,7 @@ class Menu extends BaseController if ($mmPidx > 0) { $this->menuModel->updateCnode($mmPidx, 1); } + $this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx); return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); } @@ -152,6 +162,9 @@ class Menu extends BaseController */ public function update(int $id) { + if ($deny = $this->denyUnlessLevel4Plus()) { + return $deny; + } $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { return redirect()->to(base_url('admin/select-local-government')) @@ -171,6 +184,7 @@ class Menu extends BaseController 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', ]; $this->menuModel->update($id, $data); + $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); } @@ -179,6 +193,9 @@ class Menu extends BaseController */ public function delete(int $id) { + if ($deny = $this->denyUnlessLevel4Plus()) { + return $deny; + } $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { return redirect()->to(base_url('admin/select-local-government')) @@ -190,6 +207,7 @@ class Menu extends BaseController } $result = $this->menuModel->deleteSafe($id); if ($result['ok']) { + $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); } return redirect()->back()->with('error', $result['msg']); @@ -200,6 +218,9 @@ class Menu extends BaseController */ public function move() { + if ($deny = $this->denyUnlessLevel4Plus()) { + return $deny; + } $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null) { return redirect()->to(base_url('admin/select-local-government')) @@ -209,7 +230,12 @@ class Menu extends BaseController if (! is_array($ids) || empty($ids)) { return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); } + $firstId = (int) ($ids[0] ?? 0); + $firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null; $this->menuModel->setOrder($ids, $lgIdx); + if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) { + $this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx); + } return redirect()->back()->with('success', '순서가 적용되었습니다.'); } @@ -266,4 +292,27 @@ class Menu extends BaseController return (int) $types[0]->mt_idx; } + + /** + * 메뉴 관리는 레벨4 이상(슈퍼/본부 관리자)만 허용. + * + * @return \CodeIgniter\HTTP\RedirectResponse|\CodeIgniter\HTTP\ResponseInterface|null + */ + private function denyUnlessLevel4Plus(bool $json = false) + { + $level = (int) session()->get('mb_level'); + if (Roles::isSuperAdminEquivalent($level)) { + return null; + } + + if ($json) { + return $this->response->setJSON([ + 'status' => 0, + 'msg' => '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.', + ]); + } + + return redirect()->to(base_url('admin/dashboard')) + ->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.'); + } } diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 4782211..73c2f13 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -6,8 +6,10 @@ 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; @@ -676,42 +678,2530 @@ class Bag extends BaseController // ────────────────────────────────────────────── // 불출 관리 // ────────────────────────────────────────────── + public function issueLegacy(): RedirectResponse + { + return redirect()->to(site_url('bag/issue/cancel')); + } + public function issue(): string { $lgIdx = $this->lgIdx(); - $data = ['list' => [], 'startDate' => null, 'endDate' => null]; + $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) { - $startDate = $this->request->getGet('start_date'); - $endDate = $this->request->getGet('end_date'); - $data['startDate'] = $startDate; - $data['endDate'] = $endDate; + if (! $lgIdx) { + return $this->render('불출 관리', 'bag/issue', $data); + } - $builder = model(BagIssueModel::class)->where('bi2_lg_idx', $lgIdx); - if ($startDate) $builder->where('bi2_issue_date >=', $startDate); - if ($endDate) $builder->where('bi2_issue_date <=', $endDate); - $data['list'] = $builder->orderBy('bi2_issue_date', 'DESC')->paginate(20); - $data['pager'] = model(BagIssueModel::class)->pager; + $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(); - $data = ['list' => []]; + $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) { - $invModel = model(BagInventoryModel::class); - $data['list'] = $invModel->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->paginate(20); - $data['pager'] = $invModel->pager; + $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); + 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, + * subtotals: list, + * 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 + */ + 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 + */ + 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> $rows + * @return list> + */ + 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`"); + } + } } // ────────────────────────────────────────────── @@ -885,9 +3375,147 @@ class Bag extends BaseController // --- 불출 등록 --- public function issueCreate(): string { - $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); - $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $this->lgIdx()) : []; - return $this->render('불출 처리', 'bag/create_bag_issue', compact('bagCodes')); + 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() @@ -900,7 +3528,7 @@ class Bag extends BaseController $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'))->with('success', '불출 처리되었습니다.'); + return redirect()->to(site_url('bag/issue/cancel'))->with('success', '불출 처리되었습니다.'); } public function issueCancel(int $id) @@ -908,7 +3536,7 @@ class Bag extends BaseController $admin = new \App\Controllers\Admin\BagIssue(); $admin->initController($this->request, $this->response, service('logger')); $admin->cancel($id); - return redirect()->to(site_url('bag/issue'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.'); + return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.'); } // --- 발주 등록 --- @@ -990,6 +3618,284 @@ class Bag extends BaseController ); } + /** + * 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('LOT-No 디스켓 불출', 'bag/order_lot_seed', [ + 'orders' => $orders, + 'pager' => $pager, + 'startMonth' => $startMonth, + 'endMonth' => $endMonth, + 'lotNo' => $lotNo, + 'companyIdx' => $companyIdx, + 'companies' => $companies, + 'companyMap' => $companyMap, + 'itemSummary' => $itemSummary, + ]); + } + + 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); + if ($boIdx <= 0) { + return redirect()->back()->with('error', '발주 건을 선택해 주세요.'); + } + + $order = model(BagOrderModel::class) + ->where('bo_lg_idx', $lgIdx) + ->where('bo_idx', $boIdx) + ->first(); + if (! $order) { + return redirect()->back()->with('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 redirect()->back()->with('error', '발주의 LOT/UUID 정보가 없어 seed 파일을 생성할 수 없습니다.'); + } + + $items = model(BagOrderItemModel::class) + ->where('boi_bo_idx', $boIdx) + ->orderBy('boi_idx', 'ASC') + ->findAll(); + if ($items === []) { + return redirect()->back()->with('error', '발주 품목이 없어 seed 파일을 생성할 수 없습니다.'); + } + + $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); + } + + $seedPath = $this->generateLotSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash); + $seedBinary = @file_get_contents($seedPath); + if (! is_string($seedBinary) || $seedBinary === '') { + return redirect()->back()->with('error', 'seed 파일 생성에는 성공했으나 파일을 읽을 수 없습니다.'); + } + + return $this->response + ->download($seedPath, $seedBinary) + ->setFileName(basename($seedPath)); + } + + /** + * @param array $orderData + * @param array> $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 발주 변경 화면 흐름). */ @@ -1473,12 +4379,23 @@ class Bag extends BaseController $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(); @@ -1621,12 +4538,23 @@ class Bag extends BaseController $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(); diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php index 862cf7a..486114e 100644 --- a/app/Models/MenuModel.php +++ b/app/Models/MenuModel.php @@ -189,4 +189,67 @@ class MenuModel extends Model } } + /** + * 특정 메뉴 타입(mt_idx)을 source 지자체 기준으로 모든 지자체에 재배포. + * 기존 대상 지자체의 해당 타입 메뉴는 삭제 후 source 구조로 재생성한다. + */ + public function syncTypeToAllLgs(int $mtIdx, int $sourceLg): void + { + if ($mtIdx <= 0 || $sourceLg <= 0) { + return; + } + + $source = $this->where('mt_idx', $mtIdx) + ->where('lg_idx', $sourceLg) + ->orderBy('mm_dep', 'ASC') + ->orderBy('mm_num', 'ASC') + ->findAll(); + if (empty($source)) { + return; + } + + $lgRows = $this->db->table('local_government') + ->select('lg_idx') + ->orderBy('lg_idx', 'ASC') + ->get() + ->getResultArray(); + + foreach ($lgRows as $lgRow) { + $destLg = (int) ($lgRow['lg_idx'] ?? 0); + if ($destLg <= 0 || $destLg === $sourceLg) { + continue; + } + + $this->db->transStart(); + $this->where('mt_idx', $mtIdx) + ->where('lg_idx', $destLg) + ->delete(); + + $idMap = []; + foreach ($source as $row) { + $oldId = (int) ($row->mm_idx ?? 0); + $oldP = (int) ($row->mm_pidx ?? 0); + $newPidx = 0; + if ($oldP > 0 && isset($idMap[$oldP])) { + $newPidx = (int) $idMap[$oldP]; + } + + $this->insert([ + 'mt_idx' => $mtIdx, + 'lg_idx' => $destLg, + 'mm_name' => (string) ($row->mm_name ?? ''), + 'mm_link' => (string) ($row->mm_link ?? ''), + 'mm_pidx' => $newPidx, + 'mm_dep' => (int) ($row->mm_dep ?? 0), + 'mm_num' => (int) ($row->mm_num ?? 0), + 'mm_cnode' => (int) ($row->mm_cnode ?? 0), + 'mm_level' => (string) ($row->mm_level ?? ''), + 'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'), + ]); + $idMap[$oldId] = (int) $this->getInsertID(); + } + $this->db->transComplete(); + } + } + } diff --git a/app/Views/bag/inventory_inspection_select.php b/app/Views/bag/inventory_inspection_select.php new file mode 100644 index 0000000..88acd76 --- /dev/null +++ b/app/Views/bag/inventory_inspection_select.php @@ -0,0 +1,396 @@ + + +
+
+
+
+ + + ~ + + + + + + + +
+
+ + +
+
+

※ 해당 박스와 팩을 클릭하면 팩과 낱장이 조회됩니다.

+
+ +
+
+
실사 선별자
+
+ + + + + + + + + + + + + + + $startDate, + 'end_date' => $endDate, + 'bis_id' => $selectedInspectionId, + 'item_code' => $itemCode, + 'view_type' => $viewType, + 'sel_item_id' => $itemId, + 'sel_box_code' => $boxCode, + 'sel_pack_code' => '', + ])); + ?> + + + + + + + + + + + + + + + + + + + +
실사일자종류박스전산재고실사재고
조회 결과가 없습니다.
합계
+
+
+ +
+
+
실사 선별 품목(선택 박스 조회)
+
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + $startDate, + 'end_date' => $endDate, + 'bis_id' => $selectedInspectionId, + 'item_code' => $itemCode, + 'view_type' => $viewType, + 'sel_item_id' => $selectedInspectionItemId, + 'sel_box_code' => $code, + 'sel_pack_code' => $packCode, + ])); + $isSelected = $selectedBoxCode === $code && $selectedPackCode === $packCode; + ?> + + + + + + + + + + + + + + + + + + + + + + + + +
팩코드포장량재고실사재고차이낱장(시작)낱장(끝)
+ +
선택된 품목이 없습니다.
합계
+
+
+ 선택 품목의 팩 실사수량을 입력 후 저장하세요. +
+ +
+
+
+
+ +
+
실사 선별 내용(선택 낱장 코드 조회)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No낱장전산실사차이
선택된 팩/박스가 없습니다.
합계
+
+
+
+ 실사 저장 시 차이수량이 즉시 장부 재고에 반영됩니다. +
+
+
+
+ + + + diff --git a/app/Views/bag/inventory_inspection_select_overview.php b/app/Views/bag/inventory_inspection_select_overview.php new file mode 100644 index 0000000..6ee1f07 --- /dev/null +++ b/app/Views/bag/inventory_inspection_select_overview.php @@ -0,0 +1,273 @@ + + +
+
+
+
+ + + ~ + + + + + + + +
+
+ + +
+
+

※ 실사 선별 처리 결과를 조회하는 화면입니다(읽기 전용).

+
+ +
+
+
실사 선별자
+
+ + + + + + + + + + + + + + $startDate, + 'end_date' => $endDate, + 'bis_id' => $selectedInspectionId, + 'item_code' => $itemCode, + 'view_type' => $viewType, + 'sel_item_id' => $itemId, + ])); + ?> + + + + + + + + + + + + + + + + + +
실사일자종류박스전산재고
조회 결과가 없습니다.
합계
+
+
+ +
+
+
실사 선별 품목(읽기 전용)
+
+ + + + + + + + + + + + + + $startDate, + 'end_date' => $endDate, + 'bis_id' => $selectedInspectionId, + 'item_code' => $itemCode, + 'view_type' => $viewType, + 'sel_item_id' => $selectedInspectionItemId, + 'sel_box_code' => $code, + 'sel_pack_code' => $packCode, + ])); + $isSelected = $selectedBoxCode === $code && $selectedPackCode === $packCode; + ?> + + + + + + + + + + + + + + + + + + + +
팩코드포장량재고낱장(시작)낱장(끝)
선택된 품목이 없습니다.
합계
+
+
+ +
+
실사 선별 내용(읽기 전용)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
No낱장수량
선택된 팩/박스가 없습니다.
합계
+
+
+
+
+
+ + + +