orderModel = model(BagOrderModel::class); $this->itemModel = model(BagOrderItemModel::class); } public function index() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->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]; } $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'; } $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() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(mgmt_url('bag-orders'))->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]; } $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 = []; 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[] = [ (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'] ?? ''), '', ]; } $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'); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.'); } // 봉투 종류 + 단가 + 포장단위 $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $priceMapRows = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx); $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(); $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]', 'bo_company_idx' => 'permit_empty|is_natural_no_zero', 'bo_agency_idx' => 'permit_empty|is_natural_no_zero', ]; if (! $this->validate($rules)) { 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(); 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' => $version, 'bo_lg_idx' => $lgIdx, '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'), ]; // 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성 $orderData['bo_hash'] = ''; $this->orderModel->insert($orderData); $boIdx = (int) $this->orderModel->getInsertID(); // 품목 저장 $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 ? max(1, (int) $unit->pu_total_per_box) : 1; $qtySheet = $qtySheetInput > 0 ? $qtySheetInput : ($qtyBoxInput * $totalPerBox); if ($qtySheet <= 0) { continue; } $qtyBox = intdiv($qtySheet, $totalPerBox); // 단가 (발주 변경·단가 구분 시 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; $itemData = [ 'boi_bo_idx' => $boIdx, 'boi_bag_code' => $code, 'boi_bag_name' => $detail ? $detail->cd_name : '', 'boi_unit_price' => $unitPrice, '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'], ]; } $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'); $order = $this->orderModel->find($id); if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) { return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.'); } $items = $this->itemModel->where('boi_bo_idx', $id)->findAll(); $companyName = ''; if ($order->bo_company_idx) { $c = model(CompanyModel::class)->find($order->bo_company_idx); $companyName = $c ? $c->cp_name : ''; } $agencyName = ''; if ($order->bo_agency_idx) { $a = model(SalesAgencyModel::class)->find($order->bo_agency_idx); if ($a) { $agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? ''); } } return $this->renderWorkPage('발주 상세 — ' . $order->bo_lot_no, 'admin/bag_order/detail', compact('order', 'items', 'companyName', 'agencyName')); } public function cancel(int $id) { helper('admin'); $order = $this->orderModel->find($id); if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) { return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.'); } $before = (array) $order; $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, $after); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.'); } public function delete(int $id) { helper('admin'); $order = $this->orderModel->find($id); if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) { return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.'); } $before = (array) $order; $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, $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); } }