diff --git a/app/Config/Routes.php b/app/Config/Routes.php index b8dac5b..e409ade 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -94,6 +94,7 @@ $routes->get('bag/receiving/create', 'Bag::receivingCreate'); $routes->post('bag/receiving/store', 'Bag::receivingStore'); $routes->get('bag/receiving/scanner', 'Bag::receivingScanner'); $routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore'); +$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox'); $routes->get('bag/receiving/batch', 'Bag::receivingBatch'); $routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore'); $routes->get('bag/receiving/status', 'Bag::receivingStatus'); diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 55d086c..14fb119 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -4704,12 +4704,20 @@ SQL); } /** - * 발주 입고(스캐너 대체 수동입력) - * - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리 - * - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자 - * - 인계자: 제작업체(company) 담당자 + * 발주 입고(스캐너) — 좌우 분할 워크스페이스로 렌더. */ public function receivingScanner(): string|RedirectResponse + { + return $this->renderReceivingWorkspace('scanner'); + } + + /** + * 발주입고(스캐너) / 일괄입고 공용 워크스페이스. + * 좌측: 발주 현황(탭: 제작업체·발주기간 / 인수자·인계자, 정렬) · 우측: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역. + * + * @param string $mode 'scanner'(종량제 봉투) | 'batch'(음식물·폐기물) + */ + private function renderReceivingWorkspace(string $mode): string|RedirectResponse { helper('admin'); $lgIdx = $this->lgIdx(); @@ -4717,75 +4725,206 @@ SQL); return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); } - $companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0)); - $lotNo = ''; - $bagCode = ''; + $isBatch = ($mode === 'batch'); + $category = $isBatch ? 'bulk' : 'bag'; + $title = $isBatch ? '일괄입고(음식물,폐기물)' : '발주 입고(스캐너)'; + + $tab = (string) ($this->request->getGet('tab') ?? 'company'); + if (! in_array($tab, ['company', 'agency'], true)) { + $tab = 'company'; + } + + $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0); + $agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0); + $startMonth = (string) ($this->request->getGet('start_month') ?? ''); + $endMonth = (string) ($this->request->getGet('end_month') ?? ''); + $startDate = preg_match('/^\d{4}-\d{2}$/', $startMonth) ? $startMonth . '-01' : ''; + $endDate = preg_match('/^\d{4}-\d{2}$/', $endMonth) ? date('Y-m-t', strtotime($endMonth . '-01 00:00:00')) : ''; $companies = model(CompanyModel::class) - ->where('cp_lg_idx', $lgIdx) - ->where('cp_type', '제작업체') - ->where('cp_state', 1) - ->orderBy('cp_name', 'ASC') - ->findAll(); + ->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1) + ->orderBy('cp_name', 'ASC')->findAll(); + $agencies = model(SalesAgencyModel::class) + ->where('sa_lg_idx', $lgIdx)->orderForDisplay()->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, '') - : []; + // 조회 기준: company 탭 = 제작업체+발주기간 / agency 탭 = 인수자(대행소)+인계자(제작업체) + $rows = $this->buildReceivingCandidateRows( + $lgIdx, + $companyIdx, + '', + true, + '', + $tab === 'agency' ? $agencyIdx : 0, + $category, + $tab === 'company' ? $startDate : '', + $tab === 'company' ? $endDate : '' + ); $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, - ] + $pick = $this->receivingManagerPickers($lgIdx); + $recvSel = $this->receivingReceiverSelect($lgIdx); + + // 발주기간 드롭다운 옵션(최근 5년치 월) + $monthOptions = []; + $baseYear = (int) date('Y'); + for ($y = $baseYear; $y >= $baseYear - 4; $y--) { + for ($m = 12; $m >= 1; $m--) { + $v = sprintf('%04d-%02d', $y, $m); + $monthOptions[] = ['value' => $v, 'label' => $y . '년 ' . $m . '월']; + } + } + + return $this->render($title, 'bag/receiving_scanner', [ + 'mode' => $mode, + 'isBatch' => $isBatch, + 'workspaceTitle' => $title, + 'tab' => $tab, + 'companies' => $companies, + 'agencies' => $agencies, + 'companyIdx' => $companyIdx, + 'agencyIdx' => $agencyIdx, + 'startMonth' => $startMonth, + 'endMonth' => $endMonth, + 'monthOptions' => $monthOptions, + 'receiverOptions' => $recvSel['receiverOptions'], + 'receiverRef' => $recvSel['defaultReceiverRef'], + 'senders' => $pick['senders'], + 'senderIdx' => $pick['defaultSenderIdx'], + 'rows' => $rows, + 'rowsByKey' => $rowsByKey, + ]); + } + + /** + * 실시간 입고 처리(스캔 1회 = 1박스, 또는 수량 직접입력). AJAX 전용. + * 선택 발주행에 대해 입고 레코드 생성 → 재고 반영 → 박스코드(brpc) 생성 후 반환. + */ + public function receivingScanBox(): ResponseInterface + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']); + } + + $rowKey = (string) ($this->request->getPost('row_key') ?? ''); + $receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? ''); + $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0); + $receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d')); + $barcode = trim((string) ($this->request->getPost('barcode') ?? '')); + $mode = (string) ($this->request->getPost('mode') ?? 'scanner'); + $qtySheetIn = (int) ($this->request->getPost('qty_sheet') ?? 0); + $category = ($mode === 'batch') ? 'bulk' : 'bag'; + + $refresh = ['csrf' => csrf_hash()]; + + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) { + return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고일 형식을 확인해 주세요.'])); + } + $recvSel = $this->receivingReceiverSelect($lgIdx); + $receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef); + if ($receiverRef === '') { + $receiverRef = $recvSel['defaultReceiverRef']; + } + $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef); + if ($receiverIdx <= 0) { + return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '인수자를 선택해 주세요.'])); + } + + $rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, '', 0, $category, '', ''); + $map = []; + foreach ($rows as $r) { + $map[(string) $r['row_key']] = $r; + } + if (! isset($map[$rowKey])) { + return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '선택한 발주를 찾을 수 없거나 이미 전량 입고되었습니다.'])); + } + $base = $map[$rowKey]; + $pending = (int) $base['pending_qty_sheet']; + if ($pending <= 0) { + return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '미입고 잔량이 없습니다.'])); + } + + $totalPerBox = max(1, (int) $base['total_per_box']); + $qty = $qtySheetIn > 0 ? $qtySheetIn : $totalPerBox; // 스캔 1회 = 1박스 + if ($qty > $pending) { + $qty = $pending; + } + $qtyBox = intdiv($qty, $totalPerBox); + $senderName = $this->resolveCompanySenderName($lgIdx, $senderIdx); + $sender = $senderName !== '' ? $senderName : (string) ($base['company_rep_name'] ?? ''); + + $recvModel = model(BagReceivingModel::class); + $invModel = model(BagInventoryModel::class); + $db = \Config\Database::connect(); + $db->transStart(); + $recvModel->insert([ + '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'), + ]); + $brIdx = (int) $recvModel->getInsertID(); + $invModel->adjustQty($lgIdx, (string) $base['bag_code'], (string) $base['bag_name'], $qty); + $this->createReceivingPackCodes( + $lgIdx, + $brIdx, + (int) $base['bo_idx'], + (string) $base['bag_code'], + (string) $base['bag_name'], + $qty, + max(1, (int) $base['pack_per_sheet']), + $totalPerBox, + (string) ($base['lot_no'] ?? '') ); + $db->transComplete(); + if (! $db->transStatus()) { + return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고 처리 중 오류가 발생했습니다.'])); + } + + $boxRows = $db->table('bag_receiving_pack_code') + ->select('brpc_box_code, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_sum') + ->where('brpc_br_idx', $brIdx) + ->groupBy('brpc_box_code') + ->orderBy('brpc_box_code', 'ASC') + ->get()->getResultArray(); + $boxes = array_map(static fn ($b): array => [ + 'box_code' => (string) ($b['brpc_box_code'] ?? ''), + 'packs' => (int) ($b['pack_cnt'] ?? 0), + 'sheet' => (int) ($b['sheet_sum'] ?? 0), + ], $boxRows); + + // 갱신된 미입고/입고량 재계산 + $after = $this->buildReceivingCandidateRows($lgIdx, 0, '', false, '', 0, $category, '', ''); + $updated = null; + foreach ($after as $r) { + if ((string) $r['row_key'] === $rowKey) { + $updated = $r; + break; + } + } + + return $this->response->setJSON(array_merge($refresh, [ + 'ok' => true, + 'row_key' => $rowKey, + 'bag_name' => (string) $base['bag_name'], + 'qty_sheet' => $qty, + 'qty_box' => $qtyBox, + 'received_qty_sheet' => (int) ($updated['received_qty_sheet'] ?? ((int) $base['received_qty_sheet'] + $qty)), + 'pending_qty_sheet' => (int) ($updated['pending_qty_sheet'] ?? max(0, $pending - $qty)), + 'boxes' => $boxes, + 'scanned' => $barcode, + ])); } public function receivingScannerStore(): RedirectResponse @@ -4906,51 +5045,12 @@ SQL); /** * 일괄 입고: 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, - ] - ); + return $this->renderReceivingWorkspace('batch'); } public function receivingBatchStore(): RedirectResponse @@ -5266,7 +5366,28 @@ SQL); * * @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만. */ - private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array + /** + * 봉투 이름 기준 품목 분류 (판매대장 통계와 동일 규칙). + * 반환: '음식물' | '폐기물' | '봉투' + */ + private function receivingBagCategory(string $bagName): string + { + if (mb_strpos($bagName, '음식물') !== false) { + return '음식물'; + } + if (mb_strpos($bagName, '폐기물') !== false || mb_strpos($bagName, '대형') !== false) { + return '폐기물'; + } + + return '봉투'; + } + + /** + * @param string $category '' = 전체, 'bulk' = 음식물+폐기물(일괄입고용), 'bag' = 봉투(종량제)만 + * @param string $startDate 'YYYY-MM-DD' (발주일 하한, '' = 무시) + * @param string $endDate 'YYYY-MM-DD' (발주일 상한, '' = 무시) + */ + private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = '', int $agencyIdx = 0, string $category = '', string $startDate = '', string $endDate = ''): array { $orderBuilder = model(BagOrderModel::class) ->where('bo_lg_idx', $lgIdx) @@ -5280,6 +5401,15 @@ SQL); if ($companyIdx > 0) { $orderBuilder->where('bo_company_idx', $companyIdx); } + if ($agencyIdx > 0) { + $orderBuilder->where('bo_agency_idx', $agencyIdx); + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { + $orderBuilder->where('bo_order_date >=', $startDate); + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { + $orderBuilder->where('bo_order_date <=', $endDate); + } $orders = $orderBuilder->findAll(); if (empty($orders)) { return []; @@ -5348,6 +5478,16 @@ SQL); $unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1]; $companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => '']; + $bagName = (string) ($item->boi_bag_name ?? ''); + $bagCategory = $this->receivingBagCategory($bagName); + + // 카테고리 필터 (일괄입고: 음식물+폐기물만 / bag: 봉투만) + if ($category === 'bulk' && $bagCategory === '봉투') { + continue; + } + if ($category === 'bag' && $bagCategory !== '봉투') { + continue; + } $rows[] = [ 'row_key' => $boIdx . '|' . $itemBagCode, @@ -5359,7 +5499,8 @@ SQL); '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 ?? ''), + 'bag_name' => $bagName, + 'bag_category' => $bagCategory, 'order_qty_sheet' => $orderQtySheet, 'received_qty_sheet' => $receivedQtySheet, 'pending_qty_sheet' => $pendingQtySheet, diff --git a/app/Views/bag/receiving_scanner.php b/app/Views/bag/receiving_scanner.php index 9e4993a..577bf4c 100644 --- a/app/Views/bag/receiving_scanner.php +++ b/app/Views/bag/receiving_scanner.php @@ -1,148 +1,360 @@ + $t]); +}; +?>
- 발주 입고(스캐너 대체 수동입력) +
- 일괄 입고 + 입고 현황
- +
+ +
+
발주 현황
-
-
-
- - + + - - - -

제작업체를 선택하면 해당 업체 발주 중 미입고 내역을 조회합니다.

- -

미입고 잔량이 있는 발주가 없습니다. 발주 등록 후 다시 확인해 주세요.

- -
-
- - - -
-
-
- - + +
+ +
-
- - + + +
-
- - +
+ +
-
- + +
+ +
-
상단에서 제작업체를 조회한 뒤, 아래에서 입고량(매)을 입력해 저장합니다.
+
+ + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
발주일자봉투종류발주량미입고량입고량제작업체LOT
조회 조건에 맞는 미입고 발주가 없습니다.
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
발주일자봉투종류발주량(매)미입고량(매)입고량(매)제작업체LOT NO발주NO
- -
-
+ +
+
+
선택 발주 · 입고 처리
+
+
좌측 발주 현황에서 발주를 선택하세요.
+ +
+
+ +
+
+ 입고 세부내역 (박스코드) + 박스 0 +
+
+ + + + + + + + + + + + +
No봉투종류박스코드수량
스캔/입고 시 박스코드가 추가됩니다.
+
+
+
+
diff --git a/writable/database/rename_batch_receiving_menu.sql b/writable/database/rename_batch_receiving_menu.sql new file mode 100644 index 0000000..b3f4070 --- /dev/null +++ b/writable/database/rename_batch_receiving_menu.sql @@ -0,0 +1,11 @@ +-- 메뉴명 변경: '일괄입고' → '일괄입고(음식물,폐기물)' +-- 링크(mm_link='bag/receiving/batch') 기준, 기본 이름을 쓰는 항목만 변경. +-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/rename_batch_receiving_menu.sql + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +SET CHARACTER_SET_CLIENT = utf8mb4; + +UPDATE `menu` +SET `mm_name` = '일괄입고(음식물,폐기물)' +WHERE `mm_link` = 'bag/receiving/batch' + AND `mm_name` = '일괄입고';