From 5c89c963eeff76a17b15dfc15b1c468ae65db16e Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Wed, 22 Apr 2026 15:35:36 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=B9=EC=B9=A0=20=EB=95=8C=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EB=8B=A8=EA=B0=80=EB=A5=BC=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단가 조회 공통 로직을 모델로 통합하고 발주·판매·주문·사이트 화면의 단가 계산이 모두 최신 등록 순서(bp_regdate, bp_idx DESC)를 따르도록 맞춘다. Made-with: Cursor --- app/Controllers/Admin/BagOrder.php | 856 +++++++++++++++++-- app/Controllers/Admin/BagSale.php | 2 +- app/Controllers/Admin/ShopOrder.php | 2 +- app/Controllers/Bag.php | 1197 ++++++++++++++++++++++++++- app/Models/BagPriceModel.php | 41 + 5 files changed, 1988 insertions(+), 110 deletions(-) diff --git a/app/Controllers/Admin/BagOrder.php b/app/Controllers/Admin/BagOrder.php index 2511cfb..e34b794 100644 --- a/app/Controllers/Admin/BagOrder.php +++ b/app/Controllers/Admin/BagOrder.php @@ -9,8 +9,11 @@ use App\Models\BagPriceModel; use App\Models\PackagingUnitModel; use App\Models\CompanyModel; use App\Models\SalesAgencyModel; +use App\Models\BagReceivingModel; use App\Models\CodeKindModel; use App\Models\CodeDetailModel; +use App\Models\LocalGovernmentModel; +use App\Libraries\Blockchain\SqlLedger; class BagOrder extends BaseController { private BagOrderModel $orderModel; @@ -30,36 +33,76 @@ class BagOrder extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $builder = $this->orderModel->where('bo_lg_idx', $lgIdx); - - // 기간 필터 - $startDate = $this->request->getGet('start_date'); - $endDate = $this->request->getGet('end_date'); - $status = $this->request->getGet('status'); - if ($startDate) $builder->where('bo_order_date >=', $startDate); - if ($endDate) $builder->where('bo_order_date <=', $endDate); - if ($status) $builder->where('bo_status', $status); - - $list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->paginate(20); - $pager = $this->orderModel->pager; - - // 발주별 품목 합계 - $itemSummary = []; - foreach ($list as $order) { - $items = $this->itemModel->where('boi_bo_idx', $order->bo_idx)->findAll(); - $totalQty = 0; $totalAmt = 0; - foreach ($items as $it) { $totalQty += (int) $it->boi_qty_sheet; $totalAmt += (float) $it->boi_amount; } - $itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)]; + $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]; } - // 제작업체/대행소 이름 매핑 - $companyMap = []; $agencyMap = []; - foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $c) $companyMap[$c->cp_idx] = $c->cp_name; - foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $a) { - $agencyMap[$a->sa_idx] = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? ''); + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all'); + if (! in_array($receiveType, ['all', 'received', 'pending'], true)) { + $receiveType = 'all'; } - return $this->renderWorkPage('발주 현황', 'admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status', '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] = (string) $company->cp_name; + } + + $agencyMap = []; + foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) { + $agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? ''); + } + + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $bagNameMap = []; + foreach ($bagCodes as $code) { + $bagNameMap[(string) $code->cd_code] = (string) $code->cd_name; + } + + $reportData = $this->buildOrderStatusRows( + $lgIdx, + $startMonth, + $endMonth, + $companyIdx, + $bagCode, + $receiveType, + $companyMap, + $agencyMap, + $bagNameMap + ); + + return $this->renderWorkPage( + '발주 현황', + 'admin/bag_order/index', + [ + 'startMonth' => $startMonth, + 'endMonth' => $endMonth, + 'companyIdx' => $companyIdx, + 'bagCode' => $bagCode, + 'receiveType' => $receiveType, + 'companyOptions' => $companies, + 'bagCodeOptions' => $bagCodes, + 'rows' => $reportData['rows'], + 'groupRows' => $reportData['groupRows'], + 'grandTotals' => $reportData['grandTotals'], + ] + ); } public function export() @@ -70,44 +113,240 @@ class BagOrder extends BaseController return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.'); } - $builder = $this->orderModel->where('bo_lg_idx', $lgIdx); - $startDate = $this->request->getGet('start_date'); - $endDate = $this->request->getGet('end_date'); - $status = $this->request->getGet('status'); - if ($startDate) $builder->where('bo_order_date >=', $startDate); - if ($endDate) $builder->where('bo_order_date <=', $endDate); - if ($status) $builder->where('bo_status', $status); + $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]; + } - $list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(); + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all'); + if (! in_array($receiveType, ['all', 'received', 'pending'], true)) { + $receiveType = 'all'; + } + + $companyMap = []; + foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) { + $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; + } + $agencyMap = []; + foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) { + $agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? ''); + } + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $bagNameMap = []; + foreach ($bagCodes as $code) { + $bagNameMap[(string) $code->cd_code] = (string) $code->cd_name; + } + + $reportData = $this->buildOrderStatusRows( + $lgIdx, + $startMonth, + $endMonth, + $companyIdx, + $bagCode, + $receiveType, + $companyMap, + $agencyMap, + $bagNameMap + ); $rows = []; - $statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; - foreach ($list as $row) { - $items = $this->itemModel->where('boi_bo_idx', $row->bo_idx)->findAll(); - $totalQty = 0; - $totalAmt = 0; - foreach ($items as $it) { - $totalQty += (int) $it->boi_qty_sheet; - $totalAmt += (float) $it->boi_amount; + foreach ($reportData['rows'] as $row) { + if (! empty($row['is_subtotal'])) { + $rows[] = [ + '', + '', + (string) ($row['label'] ?? '소계'), + (int) ($row['order_qty'] ?? 0), + (int) ($row['received_qty'] ?? 0), + (int) ($row['pending_qty'] ?? 0), + (float) ($row['amount'] ?? 0), + '', + ]; + + continue; } $rows[] = [ - $row->bo_idx, - $row->bo_lot_no, - $row->bo_order_date, - count($items), - $totalQty, - $totalAmt, - $statusMap[$row->bo_status] ?? $row->bo_status, + (string) ($row['order_date'] ?? ''), + (string) ($row['company_name'] ?? ''), + (string) ($row['bag_name'] ?? ''), + (int) ($row['order_qty'] ?? 0), + (int) ($row['received_qty'] ?? 0), + (int) ($row['pending_qty'] ?? 0), + (float) ($row['amount'] ?? 0), + (string) ($row['agency_name'] ?? ''), + '', ]; } - export_csv( - '발주현황_' . date('Ymd') . '.csv', - ['번호', 'LOT번호', '발주일', '품목수', '총수량', '총금액', '상태'], + $gt = $reportData['grandTotals'] ?? []; + $rows[] = [ + '', + '', + '총계', + (int) ($gt['order_qty'] ?? 0), + (int) ($gt['received_qty'] ?? 0), + (int) ($gt['pending_qty'] ?? 0), + (float) ($gt['amount'] ?? 0), + '', + '', + ]; + + export_xlsx( + '발주현황_' . date('Ymd'), + '발주현황', + ['발주일자', '제작업체', '품명', '발주수량', '입고수량', '미입고수량', '발주금액', '입고처', '비고'], $rows ); } + /** + * 발주 현황(품목 기준) 행 및 소계를 만든다. + */ + private function buildOrderStatusRows( + int $lgIdx, + string $startMonth, + string $endMonth, + int $companyIdx, + string $bagCode, + string $receiveType, + array $companyMap, + array $agencyMap, + array $bagNameMap + ): array { + $startDate = $startMonth . '-01'; + $endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00')); + + $builder = $this->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 ($companyIdx > 0) { + $builder->where('bo_company_idx', $companyIdx); + } + $orders = $builder->findAll(); + + if (empty($orders)) { + return ['rows' => [], 'groupRows' => [], 'grandTotals' => ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0]]; + } + + $orderIds = array_map(static fn($order) => (int) $order->bo_idx, $orders); + + $itemsByOrder = []; + if (! empty($orderIds)) { + $allItems = $this->itemModel + ->whereIn('boi_bo_idx', $orderIds) + ->orderBy('boi_bo_idx', 'DESC') + ->orderBy('boi_idx', 'ASC') + ->findAll(); + foreach ($allItems as $item) { + $boIdx = (int) ($item->boi_bo_idx ?? 0); + if (! isset($itemsByOrder[$boIdx])) { + $itemsByOrder[$boIdx] = []; + } + $itemsByOrder[$boIdx][] = $item; + } + } + + $receivedMap = []; + $receivingRows = model(BagReceivingModel::class) + ->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty') + ->where('br_lg_idx', $lgIdx) + ->whereIn('br_bo_idx', $orderIds) + ->groupBy('br_bo_idx, br_bag_code') + ->findAll(); + foreach ($receivingRows as $received) { + $key = (int) ($received->br_bo_idx ?? 0) . '|' . (string) ($received->br_bag_code ?? ''); + $receivedMap[$key] = (int) ($received->recv_qty ?? 0); + } + + $rows = []; + $groupRows = []; + $grandTotals = ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0.0]; + + foreach ($orders as $order) { + $boIdx = (int) ($order->bo_idx ?? 0); + $items = $itemsByOrder[$boIdx] ?? []; + $groupCount = 0; + $groupTotalOrder = 0; + $groupTotalReceived = 0; + $groupTotalPending = 0; + $groupTotalAmount = 0.0; + + foreach ($items as $item) { + $itemBagCode = (string) ($item->boi_bag_code ?? ''); + if ($bagCode !== '' && $itemBagCode !== $bagCode) { + continue; + } + + $orderQty = (int) ($item->boi_qty_sheet ?? 0); + $recvQty = (int) ($receivedMap[$boIdx . '|' . $itemBagCode] ?? 0); + if ($recvQty > $orderQty) { + $recvQty = $orderQty; + } + $pendingQty = max(0, $orderQty - $recvQty); + + if ($receiveType === 'received' && $recvQty <= 0) { + continue; + } + if ($receiveType === 'pending' && $pendingQty <= 0) { + continue; + } + + $amount = (float) ($item->boi_amount ?? 0); + $rows[] = [ + 'bo_idx' => $boIdx, + 'order_date' => (string) ($order->bo_order_date ?? ''), + 'company_name' => (string) ($companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ''), + 'bag_name' => (string) ($item->boi_bag_name ?? ($bagNameMap[$itemBagCode] ?? $itemBagCode)), + 'order_qty' => $orderQty, + 'received_qty' => $recvQty, + 'pending_qty' => $pendingQty, + 'amount' => $amount, + 'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''), + ]; + + $groupCount++; + $groupTotalOrder += $orderQty; + $groupTotalReceived += $recvQty; + $groupTotalPending += $pendingQty; + $groupTotalAmount += $amount; + } + + if ($groupCount > 0) { + $groupRows[$boIdx] = $groupCount; + $rows[] = [ + 'bo_idx' => $boIdx, + 'is_subtotal' => true, + 'label' => '소계', + 'order_qty' => $groupTotalOrder, + 'received_qty' => $groupTotalReceived, + 'pending_qty' => $groupTotalPending, + 'amount' => $groupTotalAmount, + ]; + $grandTotals['order_qty'] += $groupTotalOrder; + $grandTotals['received_qty'] += $groupTotalReceived; + $grandTotals['pending_qty'] += $groupTotalPending; + $grandTotals['amount'] += $groupTotalAmount; + } + } + + return ['rows' => $rows, 'groupRows' => $groupRows, 'grandTotals' => $grandTotals]; + } + public function create() { helper('admin'); @@ -119,18 +358,105 @@ class BagOrder extends BaseController // 봉투 종류 + 단가 + 포장단위 $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; - $prices = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_state', 1)->findAll(); - $units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll(); - $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll(); - $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); + $priceMapRows = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx); + $units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll(); - return $this->renderWorkPage('발주 등록', 'admin/bag_order/create', compact('bagCodes', 'prices', 'units', 'companies', 'agencies')); + $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll(); + $associations = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll(); + $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); + + $companyMap = []; + foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $company) { + $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; + } + $agencyMap = []; + foreach ($agencies as $agency) { + $agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? ''); + } + + $recentOrders = $this->orderModel + ->where('bo_lg_idx', $lgIdx) + ->whereLatestHead($lgIdx) + ->orderBy('bo_order_date', 'DESC') + ->orderBy('bo_idx', 'DESC') + ->findAll(12); + + $bagNameMap = []; + foreach ($bagCodes as $codeDetail) { + $bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name; + } + $priceMap = []; + foreach ($priceMapRows as $bagCode => $price) { + $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0); + } + $unitMap = []; + foreach ($units as $unit) { + $unitMap[(string) $unit->pu_bag_code] = [ + 'boxPerPack' => (int) $unit->pu_box_per_pack, + 'packPerSheet' => (int) $unit->pu_pack_per_sheet, + 'totalPerBox' => (int) $unit->pu_total_per_box, + ]; + } + + $bagReferenceRows = []; + foreach ($bagCodes as $codeDetail) { + $bagCode = (string) $codeDetail->cd_code; + $unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0]; + $bagReferenceRows[] = [ + 'code' => $bagCode, + 'name' => (string) ($bagNameMap[$bagCode] ?? ''), + 'orderPrice' => (float) ($priceMap[$bagCode] ?? 0), + 'boxPerPack' => (int) $unit['boxPerPack'], + 'packPerSheet' => (int) $unit['packPerSheet'], + 'totalPerBox' => (int) $unit['totalPerBox'], + ]; + } + + return $this->renderWorkPage( + '발주 등록', + 'admin/bag_order/create', + compact( + 'bagCodes', + 'units', + 'companies', + 'associations', + 'agencies', + 'recentOrders', + 'companyMap', + 'agencyMap', + 'bagReferenceRows' + ) + ); + } + + public function revise(int $id) + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + $order = $this->orderModel->find($id); + if (! $order || (int) $order->bo_lg_idx !== $lgIdx) { + return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.'); + } + + return redirect()->to(site_url('bag/order/revise/' . $id)); } public function store() { helper('admin'); $lgIdx = admin_effective_lg_idx(); + if ($lgIdx === null || $lgIdx <= 0) { + return redirect()->back()->withInput()->with('error', '지자체를 선택해 주세요.'); + } + + $sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0); + $sourceOrder = null; + if ($sourceIdx > 0) { + $sourceOrder = $this->orderModel->find($sourceIdx); + if (! $sourceOrder || (int) $sourceOrder->bo_lg_idx !== $lgIdx) { + return redirect()->back()->withInput()->with('error', '수정 대상 발주를 찾을 수 없습니다.'); + } + } $rules = [ 'bo_order_date' => 'required|valid_date[Y-m-d]', @@ -141,65 +467,114 @@ class BagOrder extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } + $bagCodes = $this->request->getPost('item_bag_code') ?? []; + $qtySheets = $this->request->getPost('item_qty_sheet') ?? []; + $qtyBoxes = $this->request->getPost('item_qty_box') ?? []; // 구 화면 호환 + $postedUnitPrices = $this->request->getPost('item_unit_price'); + $changeKind = (string) ($this->request->getPost('bo_change_mode') ?? 'meta'); + if (! in_array($changeKind, ['price', 'meta', 'delete'], true)) { + $changeKind = 'meta'; + } + $itemCount = count($bagCodes); + $normalizedItems = []; + for ($i = 0; $i < $itemCount; $i++) { + $code = trim((string) ($bagCodes[$i] ?? '')); + $qtySheet = (int) ($qtySheets[$i] ?? 0); + $qtyBox = (int) ($qtyBoxes[$i] ?? 0); + if ($code === '' || ($qtySheet <= 0 && $qtyBox <= 0)) { + continue; + } + $normalizedItems[] = ['code' => $code, 'qtySheet' => $qtySheet, 'qtyBox' => $qtyBox]; + } + if (empty($normalizedItems)) { + return redirect()->back()->withInput()->with('error', '최소 1개 이상의 봉투 수량을 입력해 주세요.'); + } + + $priceByCode = []; + if ($sourceOrder !== null && $changeKind === 'price' && is_array($postedUnitPrices)) { + for ($pi = 0; $pi < count($bagCodes); $pi++) { + $c = trim((string) ($bagCodes[$pi] ?? '')); + if ($c === '') { + continue; + } + $raw = $postedUnitPrices[$pi] ?? null; + if ($raw !== null && $raw !== '' && is_numeric($raw)) { + $priceByCode[$c] = round((float) $raw, 2); + } + } + } + $db = \Config\Database::connect(); $db->transStart(); - // UUID 생성 - $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - - // LOT 번호 생성 - $lotNo = 'LOT-' . date('Ymd') . '-' . strtoupper(substr(md5($uuid), 0, 6)); + try { + if ($sourceOrder) { + $uuid = (string) $sourceOrder->bo_uuid; + $maxVerRow = $this->orderModel->selectMax('bo_version')->where('bo_uuid', $uuid)->first(); + $latestVersion = ($maxVerRow !== null && isset($maxVerRow->bo_version)) ? (int) $maxVerRow->bo_version : 0; + $version = $latestVersion + 1; + $lotNo = (string) $sourceOrder->bo_lot_no; + } else { + $uuid = $this->generateUuidV4(); + $version = 1; + $lotNo = $this->generateLotNo6(); + } $orderData = [ 'bo_uuid' => $uuid, - 'bo_version' => 1, + 'bo_version' => $version, 'bo_lg_idx' => $lgIdx, - 'bo_gugun_code' => $this->request->getPost('bo_gugun_code') ?? '', - 'bo_dong_code' => $this->request->getPost('bo_dong_code') ?? '', + 'bo_gugun_code' => $this->resolveGugunCodeFromLg($lgIdx), + 'bo_dong_code' => '', 'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null, 'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null, 'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0), 'bo_order_date' => $this->request->getPost('bo_order_date'), + 'bo_bag_types' => '', + 'bo_unit_prices' => '', + 'bo_qty_boxes' => '', 'bo_lot_no' => $lotNo, 'bo_status' => 'normal', 'bo_orderer_idx' => session()->get('mb_idx'), 'bo_regdate' => date('Y-m-d H:i:s'), ]; - // SHA-256 해시 - $orderData['bo_hash'] = hash('sha256', json_encode($orderData)); + // 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성 + $orderData['bo_hash'] = ''; $this->orderModel->insert($orderData); $boIdx = (int) $this->orderModel->getInsertID(); - // CT-05: 감사 로그 - helper('audit'); - audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx])); - // 품목 저장 - $bagCodes = $this->request->getPost('item_bag_code') ?? []; - $qtyBoxes = $this->request->getPost('item_qty_box') ?? []; - foreach ($bagCodes as $i => $code) { - if (empty($code) || empty($qtyBoxes[$i])) continue; - $qtyBox = (int) $qtyBoxes[$i]; - + $hashItems = []; + $bagTypesForHeader = []; + $unitPricesForHeader = []; + $qtyBoxesForHeader = []; + foreach ($normalizedItems as $item) { + $code = $item['code']; + $qtySheetInput = (int) ($item['qtySheet'] ?? 0); + $qtyBoxInput = (int) ($item['qtyBox'] ?? 0); // 포장단위에서 낱장 환산 $unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first(); - $totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1; - $qtySheet = $qtyBox * $totalPerBox; + $totalPerBox = $unit ? max(1, (int) $unit->pu_total_per_box) : 1; + $qtySheet = $qtySheetInput > 0 ? $qtySheetInput : ($qtyBoxInput * $totalPerBox); + if ($qtySheet <= 0) { + continue; + } + $qtyBox = intdiv($qtySheet, $totalPerBox); - // 단가 - $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); + // 단가 (발주 변경·단가 구분 시 POST 단가 우선) + $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, $code); $unitPrice = $price ? (float) $price->bp_order_price : 0; + if ($sourceOrder !== null && isset($priceByCode[$code])) { + $unitPrice = $priceByCode[$code]; + } // 봉투명 $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null; - $this->itemModel->insert([ + $itemData = [ 'boi_bo_idx' => $boIdx, 'boi_bag_code' => $code, 'boi_bag_name' => $detail ? $detail->cd_name : '', @@ -207,14 +582,204 @@ class BagOrder extends BaseController 'boi_qty_box' => $qtyBox, 'boi_qty_sheet' => $qtySheet, 'boi_amount' => $unitPrice * $qtySheet, - ]); + ]; + $this->itemModel->insert($itemData); + $hashItems[] = $itemData; + + $bagTypesForHeader[] = [ + 'code' => $itemData['boi_bag_code'], + 'name' => $itemData['boi_bag_name'], + ]; + $unitPricesForHeader[] = [ + 'code' => $itemData['boi_bag_code'], + 'unit_price' => $itemData['boi_unit_price'], + ]; + $qtyBoxesForHeader[] = [ + 'code' => $itemData['boi_bag_code'], + 'qty_box' => $itemData['boi_qty_box'], + ]; } - $db->transComplete(); + $orderData['bo_bag_types'] = json_encode($bagTypesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; + $orderData['bo_unit_prices'] = json_encode($unitPricesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; + $orderData['bo_qty_boxes'] = json_encode($qtyBoxesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; + + // 최종 발주 데이터(헤더+품목) 해시 + $hashPayload = $orderData; + $hashPayload['bo_idx'] = $boIdx; + $hashPayload['items'] = $hashItems; + $hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $orderHash = hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx); + $this->orderModel->update($boIdx, [ + 'bo_bag_types' => $orderData['bo_bag_types'], + 'bo_unit_prices' => $orderData['bo_unit_prices'], + 'bo_qty_boxes' => $orderData['bo_qty_boxes'], + 'bo_hash' => $orderHash, + ]); + + $beforeHash = $sourceOrder ? (string) ($sourceOrder->bo_hash ?? '') : ''; + $seedFilePath = $this->generateBarcodeSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash); + $blockPayload = [ + 'bo_idx' => $boIdx, + 'bo_uuid' => $uuid, + 'bo_version' => $version, + 'bo_lot_no' => $lotNo, + 'bo_hash' => $orderHash, + 'seed_file' => $seedFilePath, + 'hash_chain' => $beforeHash !== '' ? [$beforeHash, $orderHash] : [$orderHash], + 'order' => $orderData, + 'items' => $hashItems, + ]; + $ledger = new SqlLedger(); + $ledger->appendBlock( + $sourceOrder ? 'ORDER_UPDATE' : 'ORDER_CREATE', + $blockPayload, + $uuid, + $version, + session()->get('mb_idx') ? (int) session()->get('mb_idx') : null, + $lgIdx + ); + + // CT-05: 감사 로그 + helper('audit'); + if ($sourceOrder) { + audit_log( + 'update', + 'bag_order', + $boIdx, + ['bo_idx' => (int) $sourceOrder->bo_idx, 'bo_hash' => $beforeHash, 'bo_version' => (int) $sourceOrder->bo_version], + array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath]) + ); + } else { + audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath])); + } + + if (! $db->transComplete()) { + throw new \RuntimeException('Transaction did not complete'); + } + } catch (\Throwable $e) { + $db->transRollback(); + log_message('error', 'BagOrder::store: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); + + return redirect()->back()->withInput()->with('error', '발주 저장 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요.'); + } return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo); } + /** 효과 지자체(`local_government`)의 행정 구·군 코드(lg_code) */ + private function resolveGugunCodeFromLg(int $lgIdx): string + { + $lg = model(LocalGovernmentModel::class)->find($lgIdx); + + return $lg ? trim((string) ($lg->lg_code ?? '')) : ''; + } + + private function generateUuidV4(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + + private function generateLotNo6(): string + { + // 문서의 "LOT 번호 6 Byte" 요구를 맞추기 위해 영숫자 6자리로 생성한다. + // 충돌 가능성을 낮추기 위해 최대 20회 재시도 후 timestamp 기반으로 fallback. + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + for ($attempt = 0; $attempt < 20; $attempt++) { + $lot = ''; + for ($i = 0; $i < 6; $i++) { + $lot .= $chars[random_int(0, strlen($chars) - 1)]; + } + + $exists = $this->orderModel->where('bo_lot_no', $lot)->countAllResults() > 0; + if (! $exists) { + return $lot; + } + } + + return strtoupper(substr(base_convert((string) time(), 10, 36), -6)); + } + + /** + * @param array $orderData + * @param array> $items + */ + private function generateBarcodeSeedFile(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 = $lotNo . '_v' . $version . '.seed.json'; + $fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName; + file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return $fullPath; + } + public function detail(int $id) { helper('admin'); @@ -250,9 +815,11 @@ class BagOrder extends BaseController } $before = (array) $order; - $this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]); + $beforeHash = (string) ($order->bo_hash ?? ''); + $this->appendLedgerForStatusChange($order, $id, 'ORDER_CANCEL', 'cancelled', $beforeHash); + $after = (array) $this->orderModel->find($id); helper('audit'); - audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']); + audit_log('update', 'bag_order', $id, $before, $after); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.'); } @@ -266,10 +833,117 @@ class BagOrder extends BaseController } $before = (array) $order; - $this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]); + $beforeHash = (string) ($order->bo_hash ?? ''); + $this->appendLedgerForStatusChange($order, $id, 'ORDER_DELETE', 'deleted', $beforeHash); + $after = (array) $this->orderModel->find($id); helper('audit'); - audit_log('delete', 'bag_order', $id, $before, ['bo_status' => 'deleted']); + audit_log('delete', 'bag_order', $id, $before, $after); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.'); } + + /** + * 상태 변경 시(취소/삭제) 무결성 검증을 위해 bo_hash 재계산 후 + * SQL-Ledger(append-only)에 블록을 추가한다. + * + * @param object $order + * @param int $boIdx + * @param string $txType ORDER_CANCEL|ORDER_DELETE + * @param string $newStatus cancelled|deleted + * @param string $previousHash + */ + private function appendLedgerForStatusChange(object $order, int $boIdx, string $txType, string $newStatus, string $previousHash): void + { + // 품목은 상태 변경 시 그대로이므로, 동일 payload 형태로 items array를 만든다. + $items = $this->itemModel->where('boi_bo_idx', $boIdx)->findAll(); + $hashItems = []; + foreach ($items as $it) { + $hashItems[] = [ + 'boi_bo_idx' => (int) $it->boi_bo_idx, + 'boi_bag_code' => (string) $it->boi_bag_code, + 'boi_bag_name' => (string) ($it->boi_bag_name ?? ''), + 'boi_unit_price' => (float) $it->boi_unit_price, + 'boi_qty_box' => (int) $it->boi_qty_box, + 'boi_qty_sheet' => (int) $it->boi_qty_sheet, + 'boi_amount' => (float) $it->boi_amount, + ]; + } + + $newOrder = $order; + $newOrder->bo_status = $newStatus; + + $newHash = $this->computeOrderHash($boIdx, $newOrder, $hashItems); + $actorIdx = session()->get('mb_idx') ? (int) session()->get('mb_idx') : null; + $lgIdx = (int) ($order->bo_lg_idx ?? 0); + + $seedFilePath = ''; + $ledgerPayload = [ + 'bo_idx' => $boIdx, + 'bo_uuid' => (string) $order->bo_uuid, + 'bo_version' => (int) $order->bo_version, + 'bo_lot_no' => (string) $order->bo_lot_no, + 'bo_hash' => $newHash, + 'seed_file' => $seedFilePath, + 'hash_chain' => [$previousHash, $newHash], + 'order' => [ + 'bo_status' => $newStatus, + 'bo_hash' => $newHash, + ], + 'items' => $hashItems, + ]; + + $ledger = new SqlLedger(); + $ledger->appendBlock( + $txType, + $ledgerPayload, + (string) $order->bo_uuid, + (int) $order->bo_version, + $actorIdx, + $lgIdx + ); + + // order row에 hash 반영 + $this->orderModel->update($boIdx, [ + 'bo_status' => $newStatus, + 'bo_moddate' => date('Y-m-d H:i:s'), + 'bo_hash' => $newHash, + ]); + } + + /** + * store()에서 생성하는 bo_hash와 동일한 "헤더+items" 규격을 사용해 SHA-256을 계산한다. + * + * @param int $boIdx + * @param object $order + * @param array> $hashItems + */ + private function computeOrderHash(int $boIdx, object $order, array $hashItems): string + { + $orderData = [ + 'bo_uuid' => (string) $order->bo_uuid, + 'bo_version' => (int) $order->bo_version, + 'bo_lg_idx' => (int) $order->bo_lg_idx, + 'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''), + 'bo_dong_code' => (string) ($order->bo_dong_code ?? ''), + 'bo_company_idx' => $order->bo_company_idx !== null ? (int) $order->bo_company_idx : null, + 'bo_agency_idx' => $order->bo_agency_idx !== null ? (int) $order->bo_agency_idx : null, + 'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0), + 'bo_order_date' => (string) $order->bo_order_date, + 'bo_bag_types' => (string) ($order->bo_bag_types ?? ''), + 'bo_unit_prices' => (string) ($order->bo_unit_prices ?? ''), + 'bo_qty_boxes' => (string) ($order->bo_qty_boxes ?? ''), + 'bo_lot_no' => (string) $order->bo_lot_no, + 'bo_hash' => '', + 'bo_status' => (string) $order->bo_status, + 'bo_orderer_idx' => $order->bo_orderer_idx !== null ? (int) $order->bo_orderer_idx : null, + 'bo_regdate' => (string) ($order->bo_regdate ?? ''), + ]; + + $hashPayload = $orderData; + $hashPayload['bo_idx'] = $boIdx; + $hashPayload['items'] = $hashItems; + + $hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx); + } } diff --git a/app/Controllers/Admin/BagSale.php b/app/Controllers/Admin/BagSale.php index 7933964..d8ae4d4 100644 --- a/app/Controllers/Admin/BagSale.php +++ b/app/Controllers/Admin/BagSale.php @@ -133,7 +133,7 @@ class BagSale extends BaseController $shop = model(DesignatedShopModel::class)->find($dsIdx); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; - $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first(); + $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $bagCode); $unitPrice = $price ? (float) $price->bp_consumer : 0; $actualQty = ($type === 'return') ? -$qty : $qty; diff --git a/app/Controllers/Admin/ShopOrder.php b/app/Controllers/Admin/ShopOrder.php index acb1fd3..57bb419 100644 --- a/app/Controllers/Admin/ShopOrder.php +++ b/app/Controllers/Admin/ShopOrder.php @@ -105,7 +105,7 @@ class ShopOrder extends BaseController } $qty = (int) $qtys[$i]; - $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); + $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $code); $unitPrice = $price ? (float) $price->bp_consumer : 0; $amount = $unitPrice * $qty; diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index de0c54b..4782211 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -21,6 +21,7 @@ use App\Models\SalesAgencyModel; use App\Models\ShopOrderModel; use App\Models\DesignatedShopModel; use App\Models\LocalGovernmentModel; +use App\Models\ManagerModel; use Config\Roles; class Bag extends BaseController @@ -34,6 +35,146 @@ class Bag extends BaseController return admin_effective_lg_idx(); } + /** + * 입고 화면용 인계자: 제작업체(company) 담당자. + * + * @return array{senders: list, defaultSenderIdx: int} + */ + private function receivingManagerPickers(int $lgIdx): array + { + $senders = model(ManagerModel::class, false) + ->where('mg_lg_idx', $lgIdx) + ->where('mg_state', 1) + ->where('mg_dept_code', 'company') + ->orderBy('mg_name', 'ASC') + ->findAll(); + + $sessionName = trim((string) (session()->get('mb_name') ?? '')); + $defaultSenderIdx = 0; + foreach ($senders as $s) { + if ((string) ($s->mg_name ?? '') === $sessionName) { + $defaultSenderIdx = (int) ($s->mg_idx ?? 0); + break; + } + } + if ($defaultSenderIdx <= 0 && $senders !== []) { + $defaultSenderIdx = (int) ($senders[0]->mg_idx ?? 0); + } + + return [ + 'senders' => $senders, + 'defaultSenderIdx' => $defaultSenderIdx, + ]; + } + + /** + * 인수자 드롭다운: 맨 위에 현재 로그인 회원, 이어서 대행소(agency) 담당자. + * value 는 br_receiver_ref 로 전달: m_{mb_idx} | g_{mg_idx} + * + * @return array{receiverOptions: list, defaultReceiverRef: string} + */ + private function receivingReceiverSelect(int $lgIdx): array + { + $sessionMbIdx = (int) (session()->get('mb_idx') ?? 0); + $sessionName = trim((string) (session()->get('mb_name') ?? '')); + $normalizeName = static fn (string $name): string => preg_replace('/\s+/u', '', trim($name)) ?? ''; + $normSession = $normalizeName($sessionName); + + $options = []; + if ($sessionMbIdx > 0) { + $label = $sessionName !== '' ? $sessionName : '로그인 사용자'; + $options[] = ['ref' => 'm_' . $sessionMbIdx, 'label' => $label]; + } + + $agencyManagers = model(ManagerModel::class, false) + ->where('mg_lg_idx', $lgIdx) + ->where('mg_state', 1) + ->where('mg_dept_code', 'agency') + ->orderBy('mg_name', 'ASC') + ->findAll(); + + foreach ($agencyManagers as $rcv) { + $mgIdx = (int) ($rcv->mg_idx ?? 0); + $receiverName = trim((string) ($rcv->mg_name ?? '')); + if ($mgIdx <= 0) { + continue; + } + if ($normSession !== '' && $normalizeName($receiverName) === $normSession) { + continue; + } + $options[] = ['ref' => 'g_' . $mgIdx, 'label' => $receiverName]; + } + + $defaultRef = $options !== [] ? (string) ($options[0]['ref'] ?? '') : ''; + + return [ + 'receiverOptions' => $options, + 'defaultReceiverRef' => $defaultRef, + ]; + } + + /** + * @param list $options + */ + private function sanitizeReceiverRef(array $options, string $ref): string + { + foreach ($options as $opt) { + if (($opt['ref'] ?? '') === $ref) { + return $ref; + } + } + + return ''; + } + + private function parseReceiverRefToStoredIdx(int $lgIdx, string $ref): int + { + $ref = trim($ref); + if (preg_match('/^m_(\d+)$/', $ref, $mm)) { + $mbIdx = (int) $mm[1]; + if ($mbIdx <= 0 || $mbIdx !== (int) (session()->get('mb_idx') ?? 0)) { + return 0; + } + + return $mbIdx; + } + if (preg_match('/^g_(\d+)$/', $ref, $mg)) { + return $this->assertAgencyReceiverIdx($lgIdx, (int) $mg[1]); + } + + return 0; + } + + private function assertAgencyReceiverIdx(int $lgIdx, int $mgIdx): int + { + if ($mgIdx <= 0) { + return 0; + } + $row = model(ManagerModel::class, false)->where([ + 'mg_idx' => $mgIdx, + 'mg_lg_idx' => $lgIdx, + 'mg_state' => 1, + 'mg_dept_code' => 'agency', + ])->first(); + + return $row ? $mgIdx : 0; + } + + private function resolveCompanySenderName(int $lgIdx, int $mgIdx): string + { + if ($mgIdx <= 0) { + return ''; + } + $row = model(ManagerModel::class, false)->where([ + 'mg_idx' => $mgIdx, + 'mg_lg_idx' => $lgIdx, + 'mg_state' => 1, + 'mg_dept_code' => 'company', + ])->first(); + + return $row ? trim((string) ($row->mg_name ?? '')) : ''; + } + private function render(string $title, string $viewFile, array $data = []): string { return view('bag/layout/main', [ @@ -395,6 +536,7 @@ class Bag extends BaseController $detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx) ->filterByTenantScope($lgIdx) ->orderBy('cd_sort', 'ASC') + ->orderBy('cd_code', 'ASC') ->orderBy('cd_idx', 'ASC') ->findAll(); @@ -447,6 +589,7 @@ class Bag extends BaseController $list = $detailModel->where('cd_ck_idx', $ckIdx) ->filterByTenantScope($lgIdx) ->orderBy('cd_sort', 'ASC') + ->orderBy('cd_code', 'ASC') ->orderBy('cd_idx', 'ASC') ->paginate(20); $pager = $detailModel->pager; @@ -499,7 +642,7 @@ class Bag extends BaseController $data['endDate'] = $endDate; // 발주 목록 - $orderBuilder = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx); + $orderBuilder = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx); if ($startDate) $orderBuilder->where('bo_order_date >=', $startDate); if ($endDate) $orderBuilder->where('bo_order_date <=', $endDate); $data['orders'] = $orderBuilder->orderBy('bo_order_date', 'DESC')->paginate(20, 'orders'); @@ -774,12 +917,276 @@ class Bag extends BaseController helper('admin'); $lgIdx = $this->lgIdx(); $companies = $lgIdx - ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->whereIn('cp_type', ['제작업체', 'manufacturer'])->where('cp_state', 1)->findAll() + ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll() + : []; + $associations = $lgIdx + ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll() : []; $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : []; $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); - $bagCodes = $kind ? model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_state', 1)->orderBy('cd_sort')->findAll() : []; - return $this->render('발주 등록', 'bag/create_bag_order', compact('companies', 'agencies', 'bagCodes')); + $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; + $units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : []; + $recentOrders = $lgIdx + ? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(12) + : []; + + $companyMap = []; + foreach ($companies as $company) { + $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; + } + $agencyMap = []; + foreach ($agencies as $agency) { + $agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? ''); + } + + $bagNameMap = []; + foreach ($bagCodes as $codeDetail) { + $bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name; + } + $priceMap = []; + foreach ($priceMapRows as $bagCode => $price) { + $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0); + } + $unitMap = []; + foreach ($units as $unit) { + $unitMap[(string) $unit->pu_bag_code] = [ + 'boxPerPack' => (int) $unit->pu_box_per_pack, + 'packPerSheet' => (int) $unit->pu_pack_per_sheet, + 'totalPerBox' => (int) $unit->pu_total_per_box, + ]; + } + + $bagReferenceRows = []; + foreach ($bagCodes as $codeDetail) { + $bagCode = (string) $codeDetail->cd_code; + $unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0]; + $bagReferenceRows[] = [ + 'code' => $bagCode, + 'name' => (string) ($bagNameMap[$bagCode] ?? ''), + 'orderPrice' => (float) ($priceMap[$bagCode] ?? 0), + 'boxPerPack' => (int) $unit['boxPerPack'], + 'packPerSheet' => (int) $unit['packPerSheet'], + 'totalPerBox' => (int) $unit['totalPerBox'], + ]; + } + + return $this->render( + '발주 등록', + 'bag/create_bag_order', + array_merge( + compact( + 'companies', + 'associations', + 'agencies', + 'bagCodes', + 'recentOrders', + 'companyMap', + 'agencyMap', + 'bagReferenceRows' + ), + ['editMode' => false, 'editDefaults' => null] + ) + ); + } + + /** + * 발주 변경 허브: 발주월·변경 구분 선택 후 목록에서 발주를 선택 (GBMS 발주 변경 화면 흐름). + */ + public function orderChange(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + $month = $this->request->getGet('month'); + if ($month === null || $month === '' || ! is_string($month) || ! preg_match('/^\d{4}-\d{2}$/', $month)) { + $month = date('Y-m'); + } + + $hubMode = $this->request->getGet('hub_mode'); + $hubMode = in_array($hubMode, ['price', 'meta', 'delete'], true) ? $hubMode : 'meta'; + + $companyMap = []; + if ($lgIdx) { + $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll(); + foreach ($companies as $company) { + $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; + } + } + + $monthOrders = []; + if ($lgIdx) { + $start = $month . '-01'; + $end = date('Y-m-t', strtotime($start . ' 00:00:00')); + $monthOrders = model(BagOrderModel::class) + ->where('bo_lg_idx', $lgIdx) + ->whereLatestHead($lgIdx) + ->where('bo_order_date >=', $start) + ->where('bo_order_date <=', $end) + ->whereIn('bo_status', ['normal', 'cancelled']) + ->orderBy('bo_order_date', 'DESC') + ->orderBy('bo_idx', 'DESC') + ->findAll(); + } + + if ($hubMode === 'delete') { + foreach ($monthOrders as $row) { + if ((string) ($row->bo_status ?? '') === 'normal') { + return redirect()->to( + site_url('bag/order/revise/' . (int) $row->bo_idx . '?change_mode=delete') + ); + } + } + if ($lgIdx) { + session()->setFlashdata('error', '해당 월에 삭제할 수 있는 발주(정상)가 없습니다.'); + } + } + + return $this->render( + '발주 변경', + 'bag/order_change', + compact('month', 'hubMode', 'monthOrders', 'companyMap') + ); + } + + public function orderRevise(int $id): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + $orderModel = model(BagOrderModel::class); + $itemModel = model(\App\Models\BagOrderItemModel::class); + + $target = $orderModel->find($id); + if (! $target || (int) $target->bo_lg_idx !== $lgIdx) { + return redirect()->to(site_url('bag/order/change'))->with('error', '수정할 발주를 찾을 수 없습니다.'); + } + if ((string) ($target->bo_status ?? '') !== 'normal') { + return redirect()->to(site_url('bag/order/change'))->with('error', '변경할 수 없는 발주입니다.'); + } + + $changeMode = $this->request->getGet('change_mode'); + $changeMode = in_array($changeMode, ['price', 'meta', 'delete'], true) ? $changeMode : 'meta'; + + $companies = $lgIdx + ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll() + : []; + $associations = $lgIdx + ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll() + : []; + $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : []; + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; + $units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : []; + $companyMap = []; + foreach ($companies as $company) { + $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; + } + $agencyMap = []; + foreach ($agencies as $agency) { + $agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? ''); + } + + $bagNameMap = []; + foreach ($bagCodes as $codeDetail) { + $bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name; + } + $priceMap = []; + foreach ($priceMapRows as $bagCode => $price) { + $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0); + } + $unitMap = []; + foreach ($units as $unit) { + $unitMap[(string) $unit->pu_bag_code] = [ + 'boxPerPack' => (int) $unit->pu_box_per_pack, + 'packPerSheet' => (int) $unit->pu_pack_per_sheet, + 'totalPerBox' => (int) $unit->pu_total_per_box, + ]; + } + + $bagReferenceRows = []; + foreach ($bagCodes as $codeDetail) { + $bagCode = (string) $codeDetail->cd_code; + $unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0]; + $bagReferenceRows[] = [ + 'code' => $bagCode, + 'name' => (string) ($bagNameMap[$bagCode] ?? ''), + 'orderPrice' => (float) ($priceMap[$bagCode] ?? 0), + 'boxPerPack' => (int) $unit['boxPerPack'], + 'packPerSheet' => (int) $unit['packPerSheet'], + 'totalPerBox' => (int) $unit['totalPerBox'], + ]; + } + + $items = $itemModel->where('boi_bo_idx', (int) $target->bo_idx)->orderBy('boi_idx', 'ASC')->findAll(); + + $orderReturnMonth = substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7); + $monthStart = $orderReturnMonth . '-01'; + $monthEnd = date('Y-m-t', strtotime($monthStart . ' 00:00:00')); + $recentOrders = $lgIdx + ? $orderModel->where('bo_lg_idx', $lgIdx) + ->whereLatestHead($lgIdx) + ->where('bo_order_date >=', $monthStart) + ->where('bo_order_date <=', $monthEnd) + ->whereIn('bo_status', ['normal', 'cancelled']) + ->orderBy('bo_order_date', 'DESC') + ->orderBy('bo_idx', 'DESC') + ->findAll() + : []; + $itemCodes = []; + $itemQtyBoxes = []; + $itemQtySheets = []; + foreach ($items as $item) { + $itemCodes[] = (string) ($item->boi_bag_code ?? ''); + $itemQtyBoxes[] = (int) ($item->boi_qty_box ?? 0); + $itemQtySheets[] = (int) ($item->boi_qty_sheet ?? 0); + } + + $savedLinePrices = []; + foreach ($items as $item) { + $savedLinePrices[(string) ($item->boi_bag_code ?? '')] = (float) ($item->boi_unit_price ?? 0); + } + foreach ($bagReferenceRows as &$brow) { + $c = (string) ($brow['code'] ?? ''); + if ($c !== '' && isset($savedLinePrices[$c])) { + $brow['orderPrice'] = $savedLinePrices[$c]; + } + } + unset($brow); + + $orderLotNo = (string) ($target->bo_lot_no ?? ''); + + $editDefaults = [ + 'bo_source_idx' => (int) $target->bo_idx, + 'bo_order_date' => (string) ($target->bo_order_date ?? date('Y-m-d')), + 'bo_order_month_ui' => substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7), + 'bo_fee_rate' => (string) ($target->bo_fee_rate ?? '0'), + 'bo_association_idx' => (string) ($target->bo_association_idx ?? ''), + 'bo_company_idx' => (string) ($target->bo_company_idx ?? ''), + 'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''), + 'item_bag_code' => $itemCodes, + 'item_qty_box' => $itemQtyBoxes, + 'item_qty_sheet' => $itemQtySheets, + ]; + + return $this->render( + '발주 변경', + 'bag/create_bag_order', + compact( + 'companies', + 'associations', + 'agencies', + 'bagCodes', + 'recentOrders', + 'companyMap', + 'agencyMap', + 'bagReferenceRows', + 'editDefaults', + 'changeMode', + 'orderReturnMonth', + 'orderLotNo' + ) + + ['editMode' => true, 'hubReturn' => true] + ); } public function orderStore() @@ -787,10 +1194,82 @@ class Bag extends BaseController $admin = new \App\Controllers\Admin\BagOrder(); $admin->initController($this->request, $this->response, service('logger')); $result = $admin->store(); - if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) { - return redirect()->to(site_url('bag/purchase-inbound'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors')); + if ($result instanceof RedirectResponse) { + $success = session()->getFlashdata('success'); + $error = session()->getFlashdata('error'); + $errors = session()->getFlashdata('errors'); + + if (! empty($error) || ! empty($errors)) { + $sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0); + $reviseMode = (string) ($this->request->getPost('bo_change_mode') ?? 'meta'); + $redirectUrl = $sourceIdx > 0 + ? site_url('bag/order/revise/' . $sourceIdx . '?change_mode=' . rawurlencode($reviseMode)) + : site_url('bag/order/create'); + + return redirect()->to($redirectUrl) + ->withInput() + ->with('error', $error) + ->with('errors', $errors); + } + + $returnHub = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1; + $returnMonth = (string) ($this->request->getPost('order_return_month') ?? ''); + $sourceIdxPost = (int) ($this->request->getPost('bo_source_idx') ?? 0); + if ($returnHub && $sourceIdxPost > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) { + return redirect()->to(site_url('bag/order/change?month=' . $returnMonth)) + ->with('success', $success ?? '발주가 저장되었습니다.'); + } + + return redirect()->to(site_url('bag/order/create')) + ->with('success', $success); } - return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '발주 등록되었습니다.'); + + $returnHub = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1; + $returnMonth = (string) ($this->request->getPost('order_return_month') ?? ''); + if ($returnHub && (int) ($this->request->getPost('bo_source_idx') ?? 0) > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) { + return redirect()->to(site_url('bag/order/change?month=' . $returnMonth))->with('success', '발주가 저장되었습니다.'); + } + + return redirect()->to(site_url('bag/order/create'))->with('success', '발주 등록되었습니다.'); + } + + public function orderDeletePost() + { + $id = (int) ($this->request->getPost('bo_idx') ?? 0); + if ($id <= 0) { + return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 발주를 선택해 주세요.'); + } + + return $this->orderDelete($id); + } + + public function orderDelete(int $id) + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if ($lgIdx === null || $lgIdx <= 0) { + return redirect()->to(site_url('bag/order/change'))->with('error', '지자체를 선택해 주세요.'); + } + $orderModel = model(BagOrderModel::class); + $order = $orderModel->find($id); + if (! $order || (int) $order->bo_lg_idx !== $lgIdx) { + return redirect()->to(site_url('bag/order/change'))->with('error', '발주를 찾을 수 없습니다.'); + } + if ((string) ($order->bo_status ?? '') !== 'normal') { + return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 수 없는 발주입니다.'); + } + $month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7); + + $admin = new \App\Controllers\Admin\BagOrder(); + $admin->initController($this->request, $this->response, service('logger')); + $response = $admin->delete($id); + if ($response instanceof RedirectResponse) { + $msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.'; + + return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', $msg); + } + + return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', '처리되었습니다.'); } public function orderCancel(int $id) @@ -816,21 +1295,705 @@ class Bag extends BaseController // --- 입고 처리 --- public function receivingCreate(): string { - helper('admin'); - $lgIdx = $this->lgIdx(); - $orders = $lgIdx ? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll() : []; - return $this->render('입고 처리', 'bag/create_bag_receiving', compact('orders')); + return $this->receivingScanner(); } public function receivingStore() { - $admin = new \App\Controllers\Admin\BagReceiving(); - $admin->initController($this->request, $this->response, service('logger')); - $result = $admin->store(); - if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) { - return redirect()->to(site_url('bag/purchase-inbound'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors')); + return $this->receivingScannerStore(); + } + + /** + * 발주 입고(스캐너 대체 수동입력) + * - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리 + * - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자 + * - 인계자: 제작업체(company) 담당자 + */ + public function receivingScanner(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); } - return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '입고 처리되었습니다.'); + + $companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0)); + $lotNo = ''; + $bagCode = ''; + + $companies = model(CompanyModel::class) + ->where('cp_lg_idx', $lgIdx) + ->where('cp_type', '제작업체') + ->where('cp_state', 1) + ->orderBy('cp_name', 'ASC') + ->findAll(); + + $defaultCompanyIdx = ! empty($companies) + ? (int) ($companies[0]->cp_idx ?? 0) + : 0; + + if ($companyIdx > 0) { + $validCompany = false; + foreach ($companies as $company) { + if ((int) ($company->cp_idx ?? 0) === $companyIdx) { + $validCompany = true; + break; + } + } + if (! $validCompany) { + $companyIdx = $defaultCompanyIdx; + } + } elseif ($defaultCompanyIdx > 0) { + // 초기 진입 시 드롭다운 최상단 제작업체를 기본 선택한다. + $companyIdx = $defaultCompanyIdx; + } + + $lotChoices = []; + $bagFilterOptions = $this->receivingBagFilterOptions($lgIdx, $companyIdx, ''); + + $pick = $this->receivingManagerPickers($lgIdx); + $recvSel = $this->receivingReceiverSelect($lgIdx); + $receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']); + $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef); + if ($receiverRef === '') { + $receiverRef = $recvSel['defaultReceiverRef']; + } + $senderIdx = (int) old('br_sender_idx', $pick['defaultSenderIdx']); + + $rows = $companyIdx > 0 + ? $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '') + : []; + $rowsByKey = []; + foreach ($rows as $row) { + $rowsByKey[(string) $row['row_key']] = $row; + } + + return $this->render( + '발주 입고(스캐너)', + 'bag/receiving_scanner', + [ + 'companyIdx' => $companyIdx, + 'companies' => $companies, + 'lotNo' => '', + 'bagCode' => '', + 'bagFilterOptions' => $bagFilterOptions, + 'lotChoices' => $lotChoices, + 'receiverOptions' => $recvSel['receiverOptions'], + 'receiverRef' => $receiverRef, + 'senders' => $pick['senders'], + 'senderIdx' => $senderIdx, + 'rows' => $rows, + 'rowsByKey' => $rowsByKey, + ] + ); + } + + public function receivingScannerStore(): RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); + } + + $receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d')); + $companyIdx = (int) ($this->request->getPost('company_idx') ?? 0); + $lotNo = ''; + $filterBagCode = ''; + $recvSel = $this->receivingReceiverSelect($lgIdx); + $receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? ''); + $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef); + if ($receiverRef === '') { + $receiverRef = $recvSel['defaultReceiverRef']; + } + $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef); + $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0); + $inputQty = $this->request->getPost('receive_qty_sheet'); + $inputQty = is_array($inputQty) ? $inputQty : []; + + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) { + return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.'); + } + if ($companyIdx <= 0) { + return redirect()->back()->withInput()->with('error', '제작업체를 선택해 주세요.'); + } + if ($receiverIdx <= 0) { + return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.'); + } + + $senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx); + + $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, ''); + $rowMap = []; + foreach ($rows as $row) { + $rowMap[(string) $row['row_key']] = $row; + } + + $insertRows = []; + foreach ($inputQty as $rowKey => $qtyRaw) { + $rowKey = (string) $rowKey; + $qty = (int) $qtyRaw; + if ($qty <= 0 || ! isset($rowMap[$rowKey])) { + continue; + } + $base = $rowMap[$rowKey]; + $pending = (int) ($base['pending_qty_sheet'] ?? 0); + if ($pending <= 0) { + continue; + } + if ($qty > $pending) { + $qty = $pending; + } + $totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1)); + $qtyBox = intdiv($qty, $totalPerBox); + $sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? ''); + + $insertRows[] = [ + 'br_bo_idx' => (int) $base['bo_idx'], + 'br_lg_idx' => $lgIdx, + 'br_bag_code' => (string) $base['bag_code'], + 'br_bag_name' => (string) $base['bag_name'], + 'br_qty_box' => $qtyBox, + 'br_qty_sheet' => $qty, + 'br_receive_date' => $receiveDate, + 'br_receiver_idx' => $receiverIdx, + 'br_sender_name' => $sender, + 'br_type' => 'scanner', + 'br_regdate' => date('Y-m-d H:i:s'), + ]; + } + + if (empty($insertRows)) { + return redirect()->back()->withInput()->with('error', '입고 처리할 수량을 입력해 주세요.'); + } + + $recvModel = model(BagReceivingModel::class); + $invModel = model(BagInventoryModel::class); + $db = \Config\Database::connect(); + $db->transStart(); + foreach ($insertRows as $row) { + $recvModel->insert($row); + $invModel->adjustQty( + $lgIdx, + (string) $row['br_bag_code'], + (string) $row['br_bag_name'], + (int) $row['br_qty_sheet'] + ); + } + $db->transComplete(); + + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', '입고 처리 중 오류가 발생했습니다.'); + } + + $query = ['company_idx' => $companyIdx]; + + return redirect()->to(site_url('bag/receiving/scanner') . '?' . http_build_query($query)) + ->with('success', count($insertRows) . '건 입고 처리되었습니다.'); + } + + /** + * 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고. + */ + public function receivingBatch(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); + } + + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + + $companies = model(CompanyModel::class) + ->where('cp_lg_idx', $lgIdx) + ->where('cp_type', '제작업체') + ->where('cp_state', 1) + ->orderBy('cp_name', 'ASC') + ->findAll(); + + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $pick = $this->receivingManagerPickers($lgIdx); + $recvSel = $this->receivingReceiverSelect($lgIdx); + $receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']); + $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef); + if ($receiverRef === '') { + $receiverRef = $recvSel['defaultReceiverRef']; + } + // 조회 화면에서는 입고완료 행도 함께 보여 미입고량 0을 확인할 수 있게 한다. + $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, ''); + + return $this->render( + '일괄 입고', + 'bag/receiving_batch', + [ + 'companyIdx' => $companyIdx, + 'bagCode' => $bagCode, + 'companies' => $companies, + 'bagCodeOptions' => $bagCodeOptions, + 'receiverOptions' => $recvSel['receiverOptions'], + 'receiverRef' => $receiverRef, + 'senders' => $pick['senders'], + 'senderIdx' => (int) old('br_sender_idx', $pick['defaultSenderIdx']), + 'rows' => $rows, + ] + ); + } + + public function receivingBatchStore(): RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); + } + + $companyIdx = (int) ($this->request->getPost('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getPost('bag_code') ?? '')); + $selected = $this->request->getPost('selected_rows'); + $selected = is_array($selected) ? array_map('strval', $selected) : []; + $receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d')); + $recvSel = $this->receivingReceiverSelect($lgIdx); + $receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? ''); + $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef); + if ($receiverRef === '') { + $receiverRef = $recvSel['defaultReceiverRef']; + } + $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef); + $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0); + + if (empty($selected)) { + return redirect()->back()->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.'); + } + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) { + return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.'); + } + if ($receiverIdx <= 0) { + return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.'); + } + + $senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx); + + $rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, ''); + $rowMap = []; + foreach ($rows as $row) { + $rowMap[(string) $row['row_key']] = $row; + } + + $insertRows = []; + foreach ($selected as $rowKey) { + if (! isset($rowMap[$rowKey])) { + continue; + } + $base = $rowMap[$rowKey]; + $qty = (int) ($base['pending_qty_sheet'] ?? 0); + if ($qty <= 0) { + continue; + } + $totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1)); + $qtyBox = intdiv($qty, $totalPerBox); + $sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? ''); + + $insertRows[] = [ + 'br_bo_idx' => (int) $base['bo_idx'], + 'br_lg_idx' => $lgIdx, + 'br_bag_code' => (string) $base['bag_code'], + 'br_bag_name' => (string) $base['bag_name'], + 'br_qty_box' => $qtyBox, + 'br_qty_sheet' => $qty, + 'br_receive_date' => $receiveDate, + 'br_receiver_idx' => $receiverIdx, + 'br_sender_name' => $sender, + 'br_type' => 'batch', + 'br_regdate' => date('Y-m-d H:i:s'), + ]; + } + + if (empty($insertRows)) { + return redirect()->back()->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.'); + } + + $recvModel = model(BagReceivingModel::class); + $invModel = model(BagInventoryModel::class); + $db = \Config\Database::connect(); + $db->transStart(); + foreach ($insertRows as $row) { + $recvModel->insert($row); + $invModel->adjustQty( + $lgIdx, + (string) $row['br_bag_code'], + (string) $row['br_bag_name'], + (int) $row['br_qty_sheet'] + ); + } + $db->transComplete(); + + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.'); + } + + return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode))) + ->with('success', count($insertRows) . '건 일괄 입고 처리되었습니다.'); + } + + public function receivingStatus(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); + } + + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all'); + if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) { + $receiveType = 'all'; + } + + $companies = model(CompanyModel::class) + ->where('cp_lg_idx', $lgIdx) + ->where('cp_type', '제작업체') + ->where('cp_state', 1) + ->orderBy('cp_name', 'ASC') + ->findAll(); + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + + $rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType); + $groupTotals = []; + $grandTotalReceive = 0; + foreach ($rows as $row) { + $key = (string) ($row['display_date'] ?? ''); + if (! isset($groupTotals[$key])) { + $groupTotals[$key] = 0; + } + $groupTotals[$key] += (int) ($row['received_qty_sheet'] ?? 0); + $grandTotalReceive += (int) ($row['received_qty_sheet'] ?? 0); + } + + return $this->render( + '입고 현황', + 'bag/receiving_status', + compact( + 'startDate', + 'endDate', + 'companyIdx', + 'bagCode', + 'receiveType', + 'companies', + 'bagCodeOptions', + 'rows', + 'groupTotals', + 'grandTotalReceive' + ) + ); + } + + public function receivingStatusExport(): RedirectResponse + { + helper(['admin', 'export']); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); + } + + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all'); + if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) { + $receiveType = 'all'; + } + + $rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType); + $exportRows = []; + foreach ($rows as $row) { + $exportRows[] = [ + (string) ($row['display_date'] ?? ''), + (string) ($row['bag_name'] ?? ''), + (int) ($row['received_qty_sheet'] ?? 0), + (string) ($row['order_date'] ?? ''), + (int) ($row['order_qty_sheet'] ?? 0), + (string) ($row['order_no'] ?? ''), + (string) ($row['company_name'] ?? ''), + (string) ($row['receive_status_label'] ?? ''), + (string) ($row['agency_name'] ?? ''), + '', + ]; + } + + export_xlsx( + '입고현황_' . date('Ymd'), + '입고현황', + ['입고일자', '품명', '입고수량', '발주일자', '발주수량', '발주번호', '제작업체', '입고여부', '입고처', '비고'], + $exportRows + ); + } + + /** + * 미입고 잔량이 있는 발주 LOT 목록(스캐너 입고용 드롭다운). + * + * @return list + */ + private function buildReceivingPendingLotChoices(int $lgIdx, int $companyIdx = 0): array + { + $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, ''); + $byLot = []; + foreach ($rows as $r) { + $lot = (string) ($r['lot_no'] ?? ''); + if ($lot === '') { + continue; + } + if (! isset($byLot[$lot])) { + $byLot[$lot] = [ + 'lot_no' => $lot, + 'bo_idx' => (int) ($r['bo_idx'] ?? 0), + 'order_date' => (string) ($r['order_date'] ?? ''), + 'company_name' => (string) ($r['company_name'] ?? ''), + 'pending_lines' => 0, + ]; + } + $byLot[$lot]['pending_lines']++; + } + $list = array_values($byLot); + usort($list, static function (array $a, array $b): int { + $da = (string) ($a['order_date'] ?? ''); + $db = (string) ($b['order_date'] ?? ''); + if ($da === $db) { + return strcmp((string) ($b['lot_no'] ?? ''), (string) ($a['lot_no'] ?? '')); + } + + return strcmp($db, $da); + }); + + return $list; + } + + private function sanitizeLotNoForReceiving(int $lgIdx, int $companyIdx, string $lotNo): string + { + $lotNo = trim($lotNo); + if ($lotNo === '') { + return ''; + } + foreach ($this->buildReceivingPendingLotChoices($lgIdx, $companyIdx) as $choice) { + if ((string) ($choice['lot_no'] ?? '') === $lotNo) { + return $lotNo; + } + } + + return ''; + } + + /** + * 선택 조건(제작업체 + LOT)에 해당하는 미입고 품목(봉투) 목록 — 조회 조건 드롭다운용. + * + * @return list + */ + private function receivingBagFilterOptions(int $lgIdx, int $companyIdx, string $lotNo = ''): array + { + if ($companyIdx <= 0) { + return []; + } + $allForFilter = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, $lotNo); + $byCode = []; + foreach ($allForFilter as $r) { + $c = (string) ($r['bag_code'] ?? ''); + if ($c === '') { + continue; + } + if (! isset($byCode[$c])) { + $byCode[$c] = (string) ($r['bag_name'] ?? ''); + } + } + $list = []; + foreach ($byCode as $code => $name) { + $list[] = ['bag_code' => $code, 'bag_name' => $name]; + } + usort($list, static fn (array $a, array $b): int => strcmp($a['bag_name'], $b['bag_name'])); + + return $list; + } + + private function sanitizeBagCodeForReceiving(int $lgIdx, int $companyIdx, string $lotNo, string $bagCode): string + { + $bagCode = trim($bagCode); + if ($bagCode === '' || $companyIdx <= 0) { + return ''; + } + foreach ($this->receivingBagFilterOptions($lgIdx, $companyIdx, $lotNo) as $opt) { + if ($opt['bag_code'] === $bagCode) { + return $bagCode; + } + } + + return ''; + } + + /** + * 입고 대상 후보(LOT-봉투행) 생성. + * + * @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만. + */ + private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array + { + $orderBuilder = model(BagOrderModel::class) + ->where('bo_lg_idx', $lgIdx) + ->whereLatestHead($lgIdx) + ->where('bo_status', 'normal') + ->orderBy('bo_order_date', 'DESC') + ->orderBy('bo_idx', 'DESC'); + if ($lotNo !== '') { + $orderBuilder->where('bo_lot_no', $lotNo); + } + if ($companyIdx > 0) { + $orderBuilder->where('bo_company_idx', $companyIdx); + } + $orders = $orderBuilder->findAll(); + if (empty($orders)) { + return []; + } + + $orderIds = array_map(static fn($o) => (int) ($o->bo_idx ?? 0), $orders); + $companyMap = []; + foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) { + $companyMap[(int) ($company->cp_idx ?? 0)] = [ + 'name' => (string) ($company->cp_name ?? ''), + 'rep' => (string) ($company->cp_rep_name ?? ''), + ]; + } + $agencyMap = []; + foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) { + $agencyMap[(int) ($agency->sa_idx ?? 0)] = (string) ($agency->sa_name ?? ''); + } + $unitMap = []; + foreach (model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() as $unit) { + $unitMap[(string) ($unit->pu_bag_code ?? '')] = [ + 'pack_per_sheet' => (int) ($unit->pu_pack_per_sheet ?? 1), + 'total_per_box' => (int) ($unit->pu_total_per_box ?? 1), + ]; + } + + $itemBuilder = model(BagOrderItemModel::class)->whereIn('boi_bo_idx', $orderIds); + if ($bagCode !== '') { + $itemBuilder->where('boi_bag_code', $bagCode); + } + $items = $itemBuilder->orderBy('boi_bo_idx', 'DESC')->orderBy('boi_idx', 'ASC')->findAll(); + + $receivedRows = model(BagReceivingModel::class) + ->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty_sheet, MAX(br_receive_date) as last_receive_date') + ->where('br_lg_idx', $lgIdx) + ->whereIn('br_bo_idx', $orderIds) + ->groupBy('br_bo_idx, br_bag_code') + ->findAll(); + $receivedMap = []; + foreach ($receivedRows as $recv) { + $receivedMap[(int) ($recv->br_bo_idx ?? 0) . '|' . (string) ($recv->br_bag_code ?? '')] = [ + 'recv_qty_sheet' => (int) ($recv->recv_qty_sheet ?? 0), + 'last_receive_date' => (string) ($recv->last_receive_date ?? ''), + ]; + } + + $orderMap = []; + foreach ($orders as $order) { + $orderMap[(int) ($order->bo_idx ?? 0)] = $order; + } + + $rows = []; + foreach ($items as $item) { + $boIdx = (int) ($item->boi_bo_idx ?? 0); + if (! isset($orderMap[$boIdx])) { + continue; + } + $order = $orderMap[$boIdx]; + $itemBagCode = (string) ($item->boi_bag_code ?? ''); + $recv = $receivedMap[$boIdx . '|' . $itemBagCode] ?? ['recv_qty_sheet' => 0, 'last_receive_date' => '']; + $orderQtySheet = (int) ($item->boi_qty_sheet ?? 0); + $receivedQtySheet = min($orderQtySheet, (int) ($recv['recv_qty_sheet'] ?? 0)); + $pendingQtySheet = max(0, $orderQtySheet - $receivedQtySheet); + if ($onlyPending && $pendingQtySheet <= 0) { + continue; + } + + $unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1]; + $companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => '']; + + $rows[] = [ + 'row_key' => $boIdx . '|' . $itemBagCode, + 'bo_idx' => $boIdx, + 'order_no' => sprintf('%06d', $boIdx), + 'lot_no' => (string) ($order->bo_lot_no ?? ''), + 'order_date' => (string) ($order->bo_order_date ?? ''), + 'company_name' => (string) ($companyInfo['name'] ?? ''), + 'company_rep_name' => (string) ($companyInfo['rep'] ?? ''), + 'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''), + 'bag_code' => $itemBagCode, + 'bag_name' => (string) ($item->boi_bag_name ?? ''), + 'order_qty_sheet' => $orderQtySheet, + 'received_qty_sheet' => $receivedQtySheet, + 'pending_qty_sheet' => $pendingQtySheet, + 'pack_per_sheet' => max(1, (int) ($unit['pack_per_sheet'] ?? 1)), + 'total_per_box' => max(1, (int) ($unit['total_per_box'] ?? 1)), + 'last_receive_date' => (string) ($recv['last_receive_date'] ?? ''), + ]; + } + + return $rows; + } + + private function buildReceivingStatusRows( + int $lgIdx, + string $startDate, + string $endDate, + int $companyIdx, + string $bagCode, + string $receiveType + ): array { + $rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, ''); + $filtered = []; + foreach ($rows as $row) { + $pendingQty = (int) ($row['pending_qty_sheet'] ?? 0); + $isCompleted = $pendingQty <= 0; + if ($receiveType === 'completed' && ! $isCompleted) { + continue; + } + if ($receiveType === 'pending' && $isCompleted) { + continue; + } + + $displayDate = (string) ($row['last_receive_date'] ?? ''); + if ($displayDate === '') { + $displayDate = (string) ($row['order_date'] ?? ''); + } + + if ($startDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) && $displayDate < $startDate) { + continue; + } + if ($endDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) && $displayDate > $endDate) { + continue; + } + + $row['display_date'] = $displayDate; + $row['receive_status_label'] = $isCompleted ? '완료' : '미완료'; + $filtered[] = $row; + } + + usort($filtered, static function (array $a, array $b): int { + $da = (string) ($a['display_date'] ?? ''); + $db = (string) ($b['display_date'] ?? ''); + if ($da === $db) { + return strcmp((string) ($a['bag_name'] ?? ''), (string) ($b['bag_name'] ?? '')); + } + + return strcmp($da, $db); + }); + + return $filtered; } // --- 판매 등록 --- diff --git a/app/Models/BagPriceModel.php b/app/Models/BagPriceModel.php index 8fb94f4..4adc3fd 100644 --- a/app/Models/BagPriceModel.php +++ b/app/Models/BagPriceModel.php @@ -16,4 +16,45 @@ class BagPriceModel extends Model 'bp_start_date', 'bp_end_date', 'bp_state', 'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx', ]; + + /** + * 같은 봉투코드에 단가 기간이 겹쳐도 "나중 등록 단가"가 우선되도록 + * 활성 단가를 등록일/PK 역순으로 정렬해 봉투코드별 1건만 남긴다. + * + * @return array + */ + public function latestActiveMapByBagCode(int $lgIdx): array + { + $rows = $this->where('bp_lg_idx', $lgIdx) + ->where('bp_state', 1) + ->orderBy('bp_regdate', 'DESC') + ->orderBy('bp_idx', 'DESC') + ->findAll(); + + $map = []; + foreach ($rows as $row) { + $code = (string) ($row->bp_bag_code ?? ''); + if ($code === '' || isset($map[$code])) { + continue; + } + $map[$code] = $row; + } + + return $map; + } + + public function latestActiveByBagCode(int $lgIdx, string $bagCode): ?object + { + $bagCode = trim($bagCode); + if ($bagCode === '') { + return null; + } + + return $this->where('bp_lg_idx', $lgIdx) + ->where('bp_bag_code', $bagCode) + ->where('bp_state', 1) + ->orderBy('bp_regdate', 'DESC') + ->orderBy('bp_idx', 'DESC') + ->first(); + } }