사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.

통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
taekyoungc
2026-06-01 16:15:15 +09:00
parent 21e7b91871
commit 0f1d414f37
129 changed files with 18068 additions and 1585 deletions

View File

@@ -0,0 +1,520 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 판매</span>
</section>
<div class="border border-gray-300 p-3 mt-2 bg-white space-y-3">
<form id="sale-save-form" action="<?= base_url('bag/sale/designated/save') ?>" method="POST">
<?= csrf_field() ?>
<input type="hidden" name="so_idx" id="save-so-idx" value=""/>
<input type="hidden" name="ds_idx" id="save-ds-idx" value=""/>
<input type="hidden" name="scans_json" id="save-scans-json" value="[]"/>
<div class="flex flex-wrap items-end gap-2">
<div class="relative">
<label class="block text-xs font-bold text-gray-700 mb-1">판매소 검색</label>
<input id="shop-search" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-[36rem] max-w-full" placeholder="코드/상호/대표자/전화/주소"/>
<datalist id="shop-search-list" class="hidden">
<?php foreach (($shops ?? []) as $shop): ?>
<option value="<?= esc(trim(($shop->ds_shop_no ?? '') . ' ' . ($shop->ds_name ?? '') . ' ' . ($shop->ds_rep_name ?? '') . ' ' . ($shop->ds_tel ?? '') . ' ' . ($shop->ds_addr ?? ''))) ?>"></option>
<?php endforeach; ?>
</datalist>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[36rem] max-w-full max-h-56 overflow-auto border border-gray-300 bg-white shadow-lg z-20"></div>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">봉투코드 입력(스캔)</label>
<input id="barcode-input" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-80" placeholder="박스/팩/낱장 바코드"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">판매일</label>
<div class="border border-gray-300 bg-gray-100 rounded px-2 py-1.5 text-sm w-36"><?= esc(date('Y-m-d')) ?></div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">판매 저장</button>
</div>
</form>
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">상호</th><td id="shop-info-name" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표자명</th><td id="shop-info-rep" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표전화</th><td id="shop-info-tel" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">주소</th><td id="shop-info-addr" class="px-3 py-1.5">-</td></tr>
</table>
</section>
<section class="xl:col-span-3 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">주문 접수 리스트</div>
<div class="max-h-56 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>판매소</th>
<th class="w-28">접수일</th>
<th class="w-28">배달일</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody id="order-list-body"></tbody>
</table>
</div>
</section>
</div>
<section id="dev-saleable-panel" class="hidden border border-amber-400 bg-amber-50/50 p-3 rounded-sm">
<p class="text-xs text-amber-900 mb-2 leading-relaxed">
<strong class="text-amber-950">[개발용 임시]</strong>
<strong>주문 접수 리스트</strong>에서 주문을 선택하면, 그 주문의 지정판매소(<code class="bg-amber-100 px-1 rounded">so_ds_idx</code>) 기준으로
판매 테스트에 쓸 수 있는 바코드 후보를 표시합니다.
(① 해당 판매소에 연결된 <code class="bg-amber-100 px-1 rounded">bag_sale_scan_code</code> 중 <code class="bg-amber-100 px-1 rounded">in_stock</code>,
② 같은 판매소의 <strong>전화·정상 주문</strong> 품목 봉투코드(수령완료 포함, 주문 리스트와 동일 범위) 및 <strong>선택한 주문</strong> 품목에 맞는 <code class="bg-amber-100 px-1 rounded">bag_receiving_pack_code</code> <code class="bg-amber-100 px-1 rounded">in_stock</code> 팩 코드.
입고 행의 <strong>수량</strong>은 팩에 담긴 <code class="bg-amber-100 px-1 rounded">brpc_sheet_qty</code>(낱장 수)입니다.)
<strong>개발 완료 후 이 블록과 API 라우트를 제거</strong>해 주세요.
</p>
<div class="max-h-52 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="w-28">출처</th>
<th class="w-40">바코드(대표)</th>
<th>봉투 종류</th>
<th class="w-16">포장</th>
<th class="w-10">수량</th>
<th class="w-12">주문</th>
<th class="w-14">상태</th>
<th>비고(낱장범위 등)</th>
</tr>
</thead>
<tbody id="dev-saleable-tbody">
<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
</tbody>
</table>
</div>
</section>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">선택</th>
<th>봉투 종류</th>
<th class="w-20">접수량</th>
<th class="w-20">판매량</th>
<th class="w-20">단가</th>
<th class="w-24">판매금액</th>
</tr>
</thead>
<tbody id="sale-items-body">
<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 상세 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>봉투 종류</th>
<th class="w-28">봉투 코드</th>
<th class="w-16">수량</th>
<th class="w-16">포장단위</th>
</tr>
</thead>
<tbody id="scan-detail-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<script>
(() => {
const shops = <?= json_encode(array_map(static function ($s): array {
return [
'ds_idx' => (int) ($s->ds_idx ?? 0),
'ds_shop_no' => (string) ($s->ds_shop_no ?? ''),
'ds_name' => (string) ($s->ds_name ?? ''),
'ds_rep_name' => (string) ($s->ds_rep_name ?? ''),
'ds_tel' => (string) ($s->ds_tel ?? ''),
'ds_addr' => trim((string) ($s->ds_addr ?? '') . ' ' . (string) ($s->ds_addr_detail ?? '')),
];
}, $shops ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const scanApi = '<?= base_url('bag/sale/designated/scan') ?>';
const devSaleableApi = '<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>';
const csrfName = '<?= csrf_token() ?>';
const csrfHash = '<?= csrf_hash() ?>';
const shopSearch = document.getElementById('shop-search');
const shopSuggest = document.getElementById('shop-search-suggest');
const barcodeInput = document.getElementById('barcode-input');
const orderListBody = document.getElementById('order-list-body');
const saleItemsBody = document.getElementById('sale-items-body');
const scanDetailBody = document.getElementById('scan-detail-body');
const saveForm = document.getElementById('sale-save-form');
const saveSoIdx = document.getElementById('save-so-idx');
const saveDsIdx = document.getElementById('save-ds-idx');
const saveScansJson = document.getElementById('save-scans-json');
const scanMessage = document.getElementById('scan-message');
const devSaleablePanel = document.getElementById('dev-saleable-panel');
const devSaleableTbody = document.getElementById('dev-saleable-tbody');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
let selectedShop = null;
let selectedOrder = null;
let selectedBagCode = '';
const pendingScans = [];
const shopMap = new Map(shops.map((s) => [String(s.ds_idx), s]));
function setMessage(msg, isError = false) {
scanMessage.textContent = msg || '';
scanMessage.className = isError ? 'text-sm text-red-600' : 'text-sm text-emerald-700';
}
function mergedShopText(shop) {
return [shop.ds_shop_no, shop.ds_name, shop.ds_rep_name, shop.ds_tel, shop.ds_addr]
.filter(Boolean)
.join(' ');
}
function hideSuggest() {
if (shopSuggest) {
shopSuggest.classList.add('hidden');
shopSuggest.innerHTML = '';
}
}
function renderSuggest(query) {
if (!shopSuggest) return;
const q = String(query || '').trim().toLowerCase();
const matched = (q
? shops.filter((s) => mergedShopText(s).toLowerCase().includes(q))
: shops
).slice(0, 30);
if (matched.length === 0) {
hideSuggest();
return;
}
shopSuggest.innerHTML = matched.map((s) => `
<button type="button" class="shop-suggest-item w-full text-left px-2 py-1.5 hover:bg-blue-50 text-xs border-b border-gray-100 whitespace-normal break-all" data-ds-idx="${s.ds_idx}">
${mergedShopText(s)}
</button>
`).join('');
shopSuggest.classList.remove('hidden');
}
function hideDevSaleablePanel() {
if (devSaleablePanel) devSaleablePanel.classList.add('hidden');
if (devSaleableTbody) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>';
}
}
async function loadDevSaleableBarcodes(dsIdx) {
if (!devSaleablePanel || !devSaleableTbody) return;
const idx = Number(dsIdx || 0);
if (!idx) {
hideDevSaleablePanel();
return;
}
devSaleablePanel.classList.remove('hidden');
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
try {
let devUrl = `${devSaleableApi}?ds_idx=${encodeURIComponent(String(idx))}`;
if (selectedOrder && Number(selectedOrder.so_idx || 0) > 0) {
devUrl += `&so_idx=${encodeURIComponent(String(selectedOrder.so_idx))}`;
}
const res = await fetch(devUrl, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) {
devSaleableTbody.innerHTML = `<tr><td colspan="8" class="text-center text-red-600 py-4">${data.message || '조회 실패'}</td></tr>`;
return;
}
const rows = Array.isArray(data.rows) ? data.rows : [];
if (rows.length === 0) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>';
return;
}
devSaleableTbody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-1">${r.source || ''}</td>
<td class="text-center font-mono">${r.code || ''}</td>
<td class="text-left pl-1">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${r.unit || ''}</td>
<td class="text-right pr-1">${nf(r.qty || 0)}</td>
<td class="text-center">${r.so_idx ? String(r.so_idx) : '-'}</td>
<td class="text-center">${r.state || ''}</td>
<td class="text-left pl-1">${r.extra || ''}</td>
</tr>
`).join('');
} catch {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
}
}
function updateShopInfo(shop) {
document.getElementById('shop-info-code').textContent = shop?.ds_shop_no || '-';
document.getElementById('shop-info-name').textContent = shop?.ds_name || '-';
document.getElementById('shop-info-rep').textContent = shop?.ds_rep_name || '-';
document.getElementById('shop-info-tel').textContent = shop?.ds_tel || '-';
document.getElementById('shop-info-addr').textContent = shop?.ds_addr || '-';
}
function renderOrderList() {
const rows = (selectedShop ? orders.filter((o) => Number(o.so_ds_idx) === Number(selectedShop.ds_idx)) : orders);
if (rows.length === 0) {
orderListBody.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-gray-400">조건에 맞는 주문이 없습니다.</td></tr>';
return;
}
orderListBody.innerHTML = rows.map((o) => `
<tr class="order-row cursor-pointer hover:bg-blue-50 ${selectedOrder && Number(selectedOrder.so_idx) === Number(o.so_idx) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${o.so_idx}</td>
<td class="text-left pl-2">${o.so_ds_name || ''}</td>
<td class="text-center">${o.so_order_date || ''}</td>
<td class="text-center">${o.so_delivery_date || ''}</td>
<td class="text-center">${o.so_status === 'cancelled' ? '주문 취소' : (Number(o.so_received || 0) === 1 ? '판매 완료' : '판매 진행')}</td>
</tr>
`).join('');
}
function pendingQtyForBag(code) {
return pendingScans.filter((s) => s.bag_code === code).reduce((sum, s) => sum + (Number(s.qty) || 0), 0);
}
function renderSaleItems() {
if (!selectedOrder) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>';
return;
}
const items = Array.isArray(selectedOrder.items) ? selectedOrder.items : [];
if (items.length === 0) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">품목 정보가 없습니다.</td></tr>';
return;
}
saleItemsBody.innerHTML = items.map((it) => {
const bagCode = String(it.soi_bag_code || '');
const sold = Number(it.sold_qty || 0) + pendingQtyForBag(bagCode);
const unitPrice = Number(it.soi_unit_price || 0);
const amount = sold * unitPrice;
const checked = selectedBagCode === bagCode ? 'checked' : '';
return `
<tr>
<td class="text-center"><input type="radio" name="pick-bag" value="${bagCode}" ${checked}></td>
<td class="text-left pl-2">${bagCode} ${it.soi_bag_name || ''}</td>
<td class="text-right pr-2">${nf(it.soi_qty || 0)}</td>
<td class="text-right pr-2">${nf(sold)}</td>
<td class="text-right pr-2">${nf(unitPrice)}</td>
<td class="text-right pr-2">${nf(amount)}</td>
</tr>
`;
}).join('');
}
function renderScanDetails() {
const rows = pendingScans.filter((s) => !selectedBagCode || s.bag_code === selectedBagCode);
if (rows.length === 0) {
scanDetailBody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>';
return;
}
scanDetailBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code} ${r.bag_name || ''}</td>
<td class="text-center">${r.barcode}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-center">${r.unit}</td>
</tr>
`).join('');
}
function selectOrder(orderId) {
selectedOrder = orders.find((o) => Number(o.so_idx) === Number(orderId)) || null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = selectedOrder ? String(selectedOrder.so_idx) : '';
saveDsIdx.value = selectedOrder ? String(selectedOrder.so_ds_idx || '') : (selectedShop ? String(selectedShop.ds_idx) : '');
saveScansJson.value = '[]';
// 주문이 선택되면 그 주문의 지정판매소를 「지정판매소 정보」 표·검색 input 에도 자동 반영한다.
if (selectedOrder) {
const matchedShop = shopMap.get(String(selectedOrder.so_ds_idx)) || null;
if (matchedShop) {
selectedShop = matchedShop;
if (shopSearch) shopSearch.value = mergedShopText(matchedShop);
updateShopInfo(matchedShop);
}
}
renderOrderList();
renderSaleItems();
renderScanDetails();
if (selectedOrder && Number(selectedOrder.so_ds_idx || 0) > 0) {
loadDevSaleableBarcodes(selectedOrder.so_ds_idx);
} else {
hideDevSaleablePanel();
}
}
async function submitScan() {
const code = (barcodeInput.value || '').trim();
if (!code) return;
if (!selectedOrder) {
setMessage('주문 접수 리스트에서 주문을 먼저 선택해 주세요.', true);
return;
}
const payload = new URLSearchParams();
payload.set(csrfName, csrfHash);
payload.set('so_idx', String(selectedOrder.so_idx));
payload.set('barcode', code);
const pendingByBag = {};
pendingScans.forEach((s) => {
const k = String(s.bag_code || '');
pendingByBag[k] = (pendingByBag[k] || 0) + (Number(s.qty || 0));
});
payload.set('pending_by_bag', JSON.stringify(pendingByBag));
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: payload.toString(),
});
const data = await res.json();
if (!data.ok) {
setMessage(data.message || '스캔 처리 실패', true);
return;
}
if (!selectedBagCode) {
selectedBagCode = String(data.bag_code || '');
}
pendingScans.push({
barcode: code,
bag_code: data.bag_code,
bag_name: data.bag_name,
qty: Number(data.qty || 0),
unit: data.unit,
pack_ids: Array.isArray(data.pack_ids) ? data.pack_ids : [],
});
saveScansJson.value = JSON.stringify(pendingScans);
barcodeInput.value = '';
setMessage(`등록 완료: ${data.bag_code} / ${data.unit} / 수량 ${nf(data.qty)}`);
renderSaleItems();
renderScanDetails();
}
shopSearch?.addEventListener('change', (e) => {
const q = String(e.target.value || '').trim().toLowerCase();
selectedShop = shops.find((s) => {
const merged = [s.ds_shop_no, s.ds_name, s.ds_rep_name, s.ds_tel, s.ds_addr].join(' ').toLowerCase();
return merged.includes(q);
}) || null;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
shopSearch?.addEventListener('input', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('focus', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('click', (e) => {
renderSuggest(e.target.value || '');
});
shopSuggest?.addEventListener('click', (e) => {
const btn = e.target.closest('.shop-suggest-item');
if (!btn) return;
const dsIdx = Number(btn.dataset.dsIdx || 0);
const shop = shops.find((s) => Number(s.ds_idx) === dsIdx) || null;
if (!shop) return;
shopSearch.value = mergedShopText(shop);
selectedShop = shop;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
document.addEventListener('click', (e) => {
if (!shopSuggest || !shopSearch) return;
if (e.target === shopSearch || shopSuggest.contains(e.target)) return;
hideSuggest();
});
barcodeInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitScan();
}
});
orderListBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-row');
if (!tr) return;
selectOrder(tr.dataset.orderId);
});
saleItemsBody?.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="pick-bag"]');
if (!radio) return;
selectedBagCode = radio.value || '';
renderScanDetails();
});
saveForm?.addEventListener('submit', (e) => {
if (!selectedOrder) {
e.preventDefault();
alert('주문을 먼저 선택해 주세요.');
return;
}
if (pendingScans.length === 0) {
e.preventDefault();
alert('없는 바코드이거나 유효한 스캔 내역이 없습니다.');
return;
}
});
renderOrderList();
renderSaleItems();
renderScanDetails();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>