diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c757f90..936dd0d 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -77,6 +77,7 @@ $routes->get('bag/order/create', 'Bag::orderCreate'); $routes->get('bag/order/phone', 'Bag::phoneOrderCreate'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); +$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan'); $routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1'); $routes->get('bag/order/change', 'Bag::orderChange'); diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index d2f8a18..9cdeb1f 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -7144,18 +7144,46 @@ SQL; } } + // 스캔(=포장·판매)된 봉투코드 내역 — 포장 명세 표시 + 품목별 포장량(soi_packed_qty) 집계에 사용 + $scansByOrder = []; + $packedByOrderBag = []; + if ($orderIds !== [] && $db->tableExists('bag_sale_scan_code')) { + $scanRows = $db->table('bag_sale_scan_code') + ->select('bssc_so_idx, bssc_bag_code, bssc_bag_name, bssc_code, bssc_unit, bssc_qty') + ->where('bssc_lg_idx', $lgIdx) + ->whereIn('bssc_so_idx', $orderIds) + ->where('bssc_state', 'sold') + ->orderBy('bssc_idx', 'ASC') + ->get()->getResultArray(); + foreach ($scanRows as $s) { + $oid = (int) ($s['bssc_so_idx'] ?? 0); + $code = (string) ($s['bssc_bag_code'] ?? ''); + $qty = (int) ($s['bssc_qty'] ?? 0); + $scansByOrder[$oid][] = [ + 'bag_code' => $code, + 'bag_name' => (string) ($s['bssc_bag_name'] ?? ''), + 'code' => (string) ($s['bssc_code'] ?? ''), + 'unit' => (string) ($s['bssc_unit'] ?? ''), + 'qty' => $qty, + ]; + $packedByOrderBag[$oid][$code] = ($packedByOrderBag[$oid][$code] ?? 0) + $qty; + } + } + $itemsByOrder = []; foreach ($itemRows as $item) { $orderId = (int) ($item->soi_so_idx ?? 0); $code = (string) ($item->soi_bag_code ?? ''); $pack = $unitMap[$code] ?? ['boxSheets' => 0, 'packSheets' => 0]; + // 포장량 = 스캔 집계 우선(포장 명세와 일치), 없으면 DB 저장값 + $packedQty = $packedByOrderBag[$orderId][$code] ?? (int) ($item->soi_packed_qty ?? 0); $itemsByOrder[$orderId][] = [ 'soi_idx' => (int) ($item->soi_idx ?? 0), 'soi_bag_code' => $code, 'soi_bag_name' => (string) ($item->soi_bag_name ?? ''), 'soi_unit_price' => (int) ($item->soi_unit_price ?? 0), 'soi_qty' => (int) ($item->soi_qty ?? 0), - 'soi_packed_qty' => (int) ($item->soi_packed_qty ?? 0), + 'soi_packed_qty' => (int) $packedQty, 'soi_amount' => (int) ($item->soi_amount ?? 0), 'soi_box_count' => (int) ($item->soi_box_count ?? 0), 'soi_pack_count' => (int) ($item->soi_pack_count ?? 0), @@ -7188,6 +7216,7 @@ SQL; 'so_total_amount' => (int) ($order->so_total_amount ?? 0), 'so_status' => (string) ($order->so_status ?? 'normal'), 'items' => $itemsByOrder[$id] ?? [], + 'scans' => $scansByOrder[$id] ?? [], ]; } @@ -7371,6 +7400,154 @@ SQL; return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.'); } + /** + * 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답). + * 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 + + * 해당 품목의 포장량(soi_packed_qty) 반영. 잔량 한도는 접수량(soi_qty). + */ + public function phoneOrderManageScan() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']); + } + + $soIdx = (int) ($this->request->getPost('so_idx') ?? 0); + $barcode = trim((string) ($this->request->getPost('barcode') ?? '')); + if ($soIdx <= 0 || $barcode === '') { + return $this->response->setJSON(['ok' => false, 'message' => '주문 또는 바코드 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]); + } + + $orderModel = model(ShopOrderModel::class); + $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first(); + if (! $order || (string) ($order->so_status ?? '') !== 'normal') { + return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]); + } + + $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode); + if (! ($scan['ok'] ?? false)) { + return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()])); + } + + $bagCode = (string) ($scan['bag_code'] ?? ''); + $orderItem = model(ShopOrderItemModel::class) + ->where('soi_so_idx', $soIdx) + ->where('soi_bag_code', $bagCode) + ->first(); + if (! $orderItem) { + return $this->response->setJSON(['ok' => false, 'message' => '선택 주문에 없는 봉투 종류입니다.', 'csrf' => csrf_hash()]); + } + + // 이미 판매(=포장)된 순수량 + $soldRow = model(BagSaleModel::class) + ->select('COALESCE(SUM(bs_qty),0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_bag_code', $bagCode) + ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel']) + ->first(); + $soldQty = (int) ($soldRow->sold_qty ?? 0); + $receiptQty = (int) ($orderItem->soi_qty ?? 0); + $scanQty = (int) ($scan['qty'] ?? 0); + $remain = max(0, $receiptQty - $soldQty); + if ($scanQty <= 0) { + return $this->response->setJSON(['ok' => false, 'message' => '수량 계산에 실패했습니다.', 'csrf' => csrf_hash()]); + } + if ($scanQty > $remain) { + return $this->response->setJSON(['ok' => false, 'message' => '접수 잔량을 초과합니다. (잔량: ' . number_format($remain) . ')', 'csrf' => csrf_hash()]); + } + + $dsIdx = (int) ($order->so_ds_idx ?? 0); + $bagName = (string) (($scan['bag_name'] ?? '') !== '' ? $scan['bag_name'] : ($orderItem->soi_bag_name ?? '')); + $unit = (string) ($scan['unit'] ?? ''); + $packIds = is_array($scan['pack_ids'] ?? null) ? $scan['pack_ids'] : []; + $now = date('Y-m-d H:i:s'); + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $db->transStart(); + + // 스캔 코드 기록(sold) + $scanCodeRow = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx)->where('bssc_code', $barcode)->get()->getRowArray(); + if (is_array($scanCodeRow)) { + $db->table('bag_sale_scan_code')->where('bssc_idx', (int) ($scanCodeRow['bssc_idx'] ?? 0))->update([ + 'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx, 'bssc_bag_code' => $bagCode, + 'bssc_bag_name' => $bagName, 'bssc_unit' => $unit, 'bssc_qty' => $scanQty, + 'bssc_state' => 'sold', 'bssc_regdate' => $now, + ]); + } else { + $db->table('bag_sale_scan_code')->insert([ + 'bssc_lg_idx' => $lgIdx, 'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx, + 'bssc_bag_code' => $bagCode, 'bssc_bag_name' => $bagName, 'bssc_code' => $barcode, + 'bssc_unit' => $unit, 'bssc_qty' => $scanQty, 'bssc_state' => 'sold', 'bssc_regdate' => $now, + ]); + } + + // 팩/박스는 수신 팩 상태를 sold로 전환(낱장은 유지) + if ($packIds !== [] && $unit !== '낱장') { + $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->whereIn('brpc_idx', array_map(static fn ($v): int => (int) $v, $packIds)) + ->set(['brpc_state' => 'sold'])->update(); + } + + // 판매 기록 + 재고 차감 + $unitPrice = (float) ($orderItem->soi_unit_price ?? 0); + model(BagSaleModel::class)->insert([ + 'bs_lg_idx' => $lgIdx, 'bs_so_idx' => $soIdx, 'bs_ds_idx' => $dsIdx, + 'bs_ds_name' => (string) ($order->so_ds_name ?? ''), 'bs_sale_date' => date('Y-m-d'), + 'bs_bag_code' => $bagCode, 'bs_bag_name' => $bagName, 'bs_qty' => $scanQty, + 'bs_unit_price' => $unitPrice, 'bs_amount' => $unitPrice * $scanQty, + 'bs_type' => 'sale', 'bs_regdate' => $now, + ]); + model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$scanQty); + + // 포장량(soi_packed_qty) 반영 + $newPacked = $soldQty + $scanQty; + model(ShopOrderItemModel::class)->update((int) ($orderItem->soi_idx ?? 0), ['soi_packed_qty' => $newPacked]); + + // 전량 판매 시 so_received=1 + $soldByBag = []; + $sumRows = model(BagSaleModel::class) + ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx)->where('bs_so_idx', $soIdx)->where('bs_type', 'sale') + ->groupBy('bs_bag_code')->findAll(); + foreach ($sumRows as $r) { + $soldByBag[(string) ($r->bs_bag_code ?? '')] = (int) ($r->sold_qty ?? 0); + } + $allItems = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdx)->findAll(); + $allSold = true; + foreach ($allItems as $it) { + if ((int) ($soldByBag[(string) ($it->soi_bag_code ?? '')] ?? 0) < (int) ($it->soi_qty ?? 0)) { + $allSold = false; + break; + } + } + $orderModel->update($soIdx, ['so_received' => $allSold ? 1 : 0]); + + $db->transComplete(); + if ($db->transStatus() === false) { + return $this->response->setJSON(['ok' => false, 'message' => '판매 처리 중 오류가 발생했습니다.', 'csrf' => csrf_hash()]); + } + + return $this->response->setJSON([ + 'ok' => true, + 'csrf' => csrf_hash(), + 'so_idx' => $soIdx, + 'soi_idx' => (int) ($orderItem->soi_idx ?? 0), + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'code' => $barcode, + 'unit' => $unit, + 'qty' => $scanQty, + 'packed_qty' => $newPacked, + 'so_received' => $allSold ? 1 : 0, + 'remain' => max(0, $receiptQty - $newPacked), + ]); + } + /** * 전화 주문 취소(삭제가 아닌 상태값 변경). */ diff --git a/app/Views/bag/order_phone_manage.php b/app/Views/bag/order_phone_manage.php index eb359df..4f4979f 100644 --- a/app/Views/bag/order_phone_manage.php +++ b/app/Views/bag/order_phone_manage.php @@ -105,6 +105,15 @@ + +
+
+ 봉투코드 스캔 + + +
+
+
포장 명세 @@ -141,6 +150,12 @@ const orders = ; const orderMap = new Map(orders.map((o) => [String(o.so_idx), o])); + // 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신) + // base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전 + const scanApi = new URL('', window.location.href).pathname; + const csrfName = ''; + let csrfHash = ''; + const listBody = document.getElementById('order-list-body'); const form = document.getElementById('order-detail-form'); const cancelForm = document.getElementById('order-cancel-form'); @@ -212,8 +227,8 @@ sheet = qty % packSheets; } - const packedInput = tr.querySelector('.item-packed-input'); - const packed = packedInput ? Math.max(0, parseInt(packedInput.value || '0', 10) || 0) : 0; + const packedCell = tr.querySelector('.item-packed-cell'); + const packed = packedCell ? Math.max(0, parseInt((packedCell.textContent || '0').replace(/[^0-9]/g, '') || '0', 10) || 0) : 0; const amount = qty * unitPrice; tr.querySelector('.item-amount-cell').textContent = nf(amount); @@ -288,9 +303,7 @@ - - - + ${nf(packedQty)} ${nf(amount)} 박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)} @@ -298,9 +311,90 @@ }).join(''); recalcTotals(); + + // 봉투코드 스캔 입력: 정상 주문일 때만 활성화 + const scanInput = document.getElementById('scan-input'); + if (scanInput) { + scanInput.disabled = isCancelled; + const msg = document.getElementById('scan-msg'); + if (msg) msg.textContent = ''; + } + syncListHeight(); // 상세 카드 높이 확정 후 접수 리스트 높이를 맞춘다 } + // 봉투코드 스캔 → 즉시 판매·포장 처리 후 화면 갱신 + const scanInputEl = document.getElementById('scan-input'); + const scanMsgEl = document.getElementById('scan-msg'); + function setScanMsg(text, isErr) { + if (!scanMsgEl) return; + scanMsgEl.textContent = text || ''; + scanMsgEl.className = 'text-xs ' + (isErr ? 'text-red-600 font-semibold' : 'text-emerald-600'); + } + async function submitScan() { + if (!scanInputEl) return; + const code = (scanInputEl.value || '').trim(); + if (!code) return; + if (!selectedId) { setScanMsg('먼저 접수 리스트에서 주문을 선택해 주세요.', true); return; } + + const body = new URLSearchParams(); + body.set(csrfName, csrfHash); + body.set('so_idx', String(selectedId)); + body.set('barcode', code); + + let data; + try { + const res = await fetch(scanApi, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + credentials: 'same-origin', + body: body.toString(), + }); + data = await res.json(); + } catch (e) { + setScanMsg('통신 오류가 발생했습니다.', true); + return; + } + if (data && data.csrf) csrfHash = data.csrf; // 다음 스캔용 토큰 갱신 + if (!data || !data.ok) { + setScanMsg((data && data.message) || '스캔 처리 실패', true); + scanInputEl.select(); + return; + } + + // 선택된 주문 객체에 스캔 반영(재선택 시 유지) + const order = orderMap.get(String(data.so_idx)); + if (order) { + if (!Array.isArray(order.scans)) order.scans = []; + order.scans.push({ bag_code: data.bag_code, bag_name: data.bag_name, code: data.code, unit: data.unit, qty: Number(data.qty || 0) }); + const it = (order.items || []).find((x) => String(x.soi_bag_code) === String(data.bag_code)); + if (it) it.soi_packed_qty = Number(data.packed_qty || 0); + order.so_received = Number(data.so_received || 0); + // 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신 + if (String(selectedId) === String(data.so_idx)) { + renderPacking(order); + const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]'); + if (cell) { + cell.textContent = nf(Number(data.packed_qty || 0)); + const row = cell.closest('tr'); + const qtyInp = row ? row.querySelector('.item-qty-input') : null; + const rq = qtyInp ? (parseInt(qtyInp.value || '0', 10) || 0) : 0; + cell.classList.toggle('text-emerald-600', Number(data.packed_qty || 0) >= rq && rq > 0); + cell.classList.toggle('text-gray-700', !(Number(data.packed_qty || 0) >= rq && rq > 0)); + } + recalcTotals(); + } + // 접수 리스트의 '포장' 표시(O/X) 갱신 + renderList(); + } + scanInputEl.value = ''; + scanInputEl.focus(); + setScanMsg('등록: ' + data.bag_code + ' / ' + data.unit + ' / ' + nf(data.qty) + ' (잔량 ' + nf(data.remain) + ')', false); + } + scanInputEl?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); submitScan(); } + }); + // 접수 리스트 스크롤 높이를 지정판매소 정보(상세) 카드 높이에 맞춘다. // offsetHeight는 화면 zoom(텍스트 줄이기)과 무관한 레이아웃 px라, 배율과 상관없이 두 카드가 같은 높이가 되고 하단 공백이 생기지 않는다. const listScrollEl = document.getElementById('list-scroll'); @@ -326,42 +420,27 @@ const packingBody = document.getElementById('packing-body'); const packingTotal = document.getElementById('packing-total'); function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); } + // 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시. function renderPacking(order) { if (!packingBody) return; - const items = (Array.isArray(order.items) ? order.items.slice() : []) - .sort((a, b) => (Number(b.soi_idx) || 0) - (Number(a.soi_idx) || 0)); // 내림차순 - const rows = []; + const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : []; let no = 0; - const addRow = (code, name, qty, unit) => { + let totalQty = 0; + const rows = scans.map((s) => { no++; - rows.push('' + no + '' + const code = s.bag_code || ''; + const name = s.bag_name || ''; + const qty = parseInt(s.qty || 0, 10) || 0; + totalQty += qty; + return '' + no + '' + '' + packEsc(code) + ' ' + packEsc(name) + '' - + '-' + + '' + packEsc(s.code || '') + '' + '' + nf(qty) + '' - + '' + unit + ''); - }; - items.forEach((it) => { - const code = it.soi_bag_code || ''; - const name = it.soi_bag_name || ''; - let qty = parseInt(it.soi_qty || 0, 10) || 0; - const boxSheets = parseInt(it.box_sheets || 0, 10) || 0; - const packSheets = parseInt(it.pack_sheets || 0, 10) || 0; - let box = 0, pack = 0, sheet = qty; - if (boxSheets > 0) { - box = Math.floor(qty / boxSheets); - const rem = qty % boxSheets; - if (packSheets > 0) { pack = Math.floor(rem / packSheets); sheet = rem % packSheets; } - else { sheet = rem; } - } else if (packSheets > 0) { - pack = Math.floor(qty / packSheets); sheet = qty % packSheets; - } - for (let i = 0; i < box; i++) { addRow(code, name, boxSheets, '박스'); } - for (let i = 0; i < pack; i++) { addRow(code, name, packSheets, '팩'); } - if (sheet > 0) { addRow(code, name, sheet, '낱장'); } + + '' + packEsc(s.unit || '') + ''; }); packingBody.innerHTML = rows.length ? rows.join('') - : '포장할 품목이 없습니다.'; - if (packingTotal) packingTotal.textContent = String(no); + : '스캔된 포장 내역이 없습니다.'; + if (packingTotal) packingTotal.textContent = nf(totalQty); } // ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────