feat(입고 처리): 매입발주 선택 → 라인별 창고/수량 입고 (부분/전체) + 매뉴얼 보강
Deploy momo-erp / deploy (push) Successful in 51s
Deploy momo-erp / deploy (push) Successful in 51s
[입고 처리 화면 재설계 — 등록 → 수정 방식] - 좌-우 분할: · 좌: 매입 발주서 리스트 (발주요청+입고중 기본 필터) · 우: 발주 라인별 [창고 선택 + 정상 입고 + 불량] 인라인 입력 - 발주/입고/미입고 한눈에 표시 (예: 10 / 5 / 5) - 완전 입고된 라인은 ✓ 완료 표시 + 입력 칸 잠김 - 정상+불량은 남은 수량(qty - received_qty) 이하로 자동 클램프 [/api/m/procurements/list] - 응답에 TOTAL_QTY, RECEIVED_QTY 추가 → 좌측 리스트에 진척 표시 [/api/m/inbounds/save] - procObjid 있으면 라인별 입고 한도 사전 검증 (qty - received_qty 초과 차단) - 0 입고 라인은 건너뛰기 - 매입발주 상태 자동 갱신: · 모든 라인 완전 입고 → RECEIVED (입고완료) · 일부 라인만 입고 → PARTIAL (입고중) · 시작 안 함 → REQUESTED 유지 [매뉴얼 — 가-1, 가-2, 다-2 대폭 보강] - 거래처 출고 요청: 6단계 체크리스트 + 화면 도식 + 토스트/모달 예시 + 시나리오 - 내 주문 내역 + 거래처 자기 주문 수량 수정/품목 삭제/취소: 화면 도식 + 단계별 가이드 + 상태표 - 입고 처리: 화면 도식 + 발주/입고/미입고 표시 의미 + 부분입고 시나리오 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+299
-20
@@ -294,8 +294,135 @@
|
||||
<p class="lead">가게 사장님이 모모유통에서 물건을 주문할 때 쓰는 화면이에요. 컴퓨터로도 핸드폰으로도 잘 보입니다.</p>
|
||||
|
||||
<!-- 가.1 주문하기 -->
|
||||
<h3 id="u-order-new">가-1. 물건 주문하기 — 가장 많이 쓰는 화면</h3>
|
||||
<p>로그인하면 자동으로 이 화면이 열려요.</p>
|
||||
<h3 id="u-order-new">가-1. 물건 주문하기 — 한 번에 따라하기</h3>
|
||||
<p class="lead">가게(거래처) 사장님이 모모유통에 물건을 주문하는 화면입니다. 로그인하면 자동으로 이 화면이 열려요. 컴퓨터·휴대폰 어디서든 잘 보여요.</p>
|
||||
|
||||
<h4>① 로그인 후 첫 화면 — 주문 화면</h4>
|
||||
<div class="screen">
|
||||
<div class="browser-bar"><span class="dots"><i></i><i></i><i></i></span><span class="url">momotogether.com</span></div>
|
||||
<div class="header-bar"><span><b>📦 모모유통</b></span><span>👤 우리가게 · 로그아웃</span></div>
|
||||
<div class="body">
|
||||
<div style="background:#fff;border:2px solid #6ee7b7;border-radius:10px;padding:10px;margin-bottom:10px">
|
||||
<div class="row" style="border:0">
|
||||
<span>🛒 <b>장바구니</b> <span class="badge b-sage">0</span></span>
|
||||
<span><span class="btn btn-emerald" style="opacity:.5">주문하기</span> ▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#94a3b8;font-size:11.5px;margin:0 0 10px">▲ 처음에는 비어있어요. 아래에서 물건을 담으면 숫자가 올라가요.</p>
|
||||
<div class="row"><span>🔍 검색창에 <b>물건 이름·코드</b> 입력</span><span>전체 / 면세 / 과세 ▾ <span class="btn btn-emerald">검색</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>② 물건 검색해서 카드 보기</h4>
|
||||
<p>"꽃계탕"이라고 검색창에 치고 [검색] 또는 <kbd>엔터</kbd>를 누르면 아래에 물건 카드가 나와요. 카드는 사진·이름·가격·재고·뱃지 모양으로 보여요.</p>
|
||||
<div class="screen">
|
||||
<div class="body">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px">
|
||||
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;background:#fff;text-align:center">
|
||||
📦<br>
|
||||
<b>꽃계탕</b> <span class="badge b-violet">면세</span><br>
|
||||
<small>제조사명</small><br>
|
||||
<b style="color:#0f766e">4,500원</b><br>
|
||||
<small>창고에 212개 있음</small><br>
|
||||
<span class="btn btn-emerald" style="display:block;margin-top:6px">+ 담기</span>
|
||||
</div>
|
||||
<div style="border:1px solid #fdba74;border-radius:8px;padding:12px;background:#fff7ed;text-align:center">
|
||||
📦<br>
|
||||
<b>김치찌개</b> <span class="badge b-orange">택배만</span><br>
|
||||
<small>택배 전용 물건</small><br>
|
||||
<b style="color:#0f766e">12,000원</b><br>
|
||||
<small>창고에 50개 있음</small><br>
|
||||
<span class="btn btn-emerald" style="display:block;margin-top:6px">+ 담기</span>
|
||||
</div>
|
||||
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;background:#fff;text-align:center;opacity:.5">
|
||||
📦<br>
|
||||
<b>참치캔</b><br>
|
||||
<b style="color:#e11d48">품절</b><br>
|
||||
<small>창고에 0개</small><br>
|
||||
<span class="btn btn-slate" style="display:block;margin-top:6px;opacity:.5">+ 담기</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>카드의 표시들 :</p>
|
||||
<ul>
|
||||
<li><span class="badge b-violet">면세</span> — 세금이 붙지 않는 물건 (그냥 참고)</li>
|
||||
<li><span class="badge b-orange">택배만</span> — <b>이 물건은 택배로만 보내요.</b> 카드를 담으면 자동으로 '택배비' 줄이 장바구니에 1줄 더 추가됩니다.</li>
|
||||
<li>"한 번에 30개까지만" — 한 번 주문에 살 수 있는 최대 개수. 더 사고 싶으면 모모 직원에게 권한을 풀어달라고 하세요.</li>
|
||||
<li>창고 수량이 0이면 [+ 담기] 버튼이 회색으로 막혀요.</li>
|
||||
</ul>
|
||||
|
||||
<h4>③ [+ 담기] 눌러서 장바구니에 넣기</h4>
|
||||
<p>버튼을 한 번 누르면 1개 들어가요. 같은 카드 [+ 담기]를 또 누르면 2개로 늘어요. 다음 같은 토스트가 우상단에 잠깐 뜨면 추가 성공:</p>
|
||||
<div class="tip">✅ 장바구니에 추가됨: 꽃계탕</div>
|
||||
<p>위쪽 초록색 <b>장바구니 바</b>의 숫자가 0 → 1 → 2 로 올라가는 게 보일 거예요.</p>
|
||||
|
||||
<h4>④ 장바구니 펼쳐서 개수 조절 / 택배·용차 추가</h4>
|
||||
<p>위쪽 <b>장바구니 바</b>를 클릭하면 펼쳐져요. 담은 물건 줄과 함께 <b>[+ 택배 추가]</b> <b>[+ 용차 추가]</b> 버튼이 보입니다.</p>
|
||||
<div class="screen">
|
||||
<div class="body">
|
||||
<div style="background:#fff;border:2px solid #6ee7b7;border-radius:10px;padding:12px">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<span class="btn btn-light-orange">+ 택배 추가</span>
|
||||
<span class="btn btn-light-sky">+ 용차 추가</span>
|
||||
<span style="font-size:11px;color:#94a3b8;margin-left:auto">전체 삭제</span>
|
||||
</div>
|
||||
<div style="background:#fff7ed;border:1px solid #fdba74;border-radius:8px;padding:8px;display:flex;gap:6px;align-items:center;font-size:12px;margin-bottom:6px">
|
||||
<span class="badge b-orange">택배</span>
|
||||
<span style="flex:1">담당자/메모</span>
|
||||
<span style="border:1px solid #cbd5e1;padding:2px 8px;background:#fff">4000</span>×<span style="border:1px solid #cbd5e1;padding:2px 6px;background:#fff">1</span>=
|
||||
<b>₩4,000</b>
|
||||
<span style="color:#94a3b8">×</span>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:8px;display:flex;justify-content:space-between;align-items:center;font-size:12px">
|
||||
<b>꽃계탕</b>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<span class="btn btn-slate" style="padding:1px 8px">−</span>
|
||||
<span style="border:1px solid #cbd5e1;padding:2px 8px;background:#fff">2</span>
|
||||
<span class="btn btn-slate" style="padding:1px 8px">+</span>
|
||||
<span style="margin-left:8px"><b>₩9,000</b></span>
|
||||
<span style="color:#94a3b8;margin-left:6px">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>− / +</b> 버튼이나 숫자 직접 입력으로 개수 조절. 재고/한도를 넘으면 자동으로 막힘.</li>
|
||||
<li>같은 종류 [+ 택배 추가]를 두 번 누르면 <b>새 줄이 안 생기고 그 줄 수량이 +1</b>로 올라가요.</li>
|
||||
<li>택배비 기본 4,000원 / 용차비 기본 5,000원 — 직접 가격·개수·담당자명을 고칠 수 있어요.</li>
|
||||
<li>X 버튼으로 줄을 빼면 그 줄만 삭제 (택배 전용 물건이 있는 동안에는 택배 줄을 못 빼요).</li>
|
||||
</ul>
|
||||
|
||||
<h4>⑤ [주문하기] 눌러서 주문 확정</h4>
|
||||
<p>장바구니 바 우측 [주문하기] 버튼을 클릭하면 확인 알림이 떠요:</p>
|
||||
<div class="note">
|
||||
<b>발주를 요청하시겠습니까?</b><br>
|
||||
합계 ₩27,200 (품목 2, 부가 1)<br>
|
||||
<span class="btn btn-emerald">발주</span> <span class="btn btn-slate">취소</span>
|
||||
</div>
|
||||
<p>[발주] 누르면 끝. <b>주문 번호</b>가 만들어지고 (예: <code>ORD-20260507-0001</code>) 자동으로 [내 주문 내역] 화면으로 이동합니다.</p>
|
||||
|
||||
<div class="warn">
|
||||
<b>⚠️ 주의</b>
|
||||
<ul style="margin:6px 0 0;padding-left:20px">
|
||||
<li>택배 전용 물건이 들어 있는데 택배비 줄이 없으면 "택배 전용 품목이 들어있어 택배줄이 필요해요" 라고 막힙니다 — [+ 택배 추가] 누르고 다시 시도하세요.</li>
|
||||
<li>택배비/용차비 가격이나 개수가 0이면 못 보냅니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="scenario">
|
||||
<h4>📘 처음부터 끝까지 따라하기 — "꽃계탕 2개 주문"</h4>
|
||||
<ol>
|
||||
<li><b>로그인</b> → 자동으로 주문 화면이 열림</li>
|
||||
<li>검색창에 "꽃계탕" 치고 <kbd>엔터</kbd></li>
|
||||
<li>꽃계탕 카드의 [+ 담기]를 <b>두 번</b> 클릭 → 우상단 토스트 두 번 뜸</li>
|
||||
<li>위쪽 초록색 장바구니 바 숫자가 1 → 2 로 변함</li>
|
||||
<li>장바구니 바 클릭 → 펼쳐서 "꽃계탕 2개 ₩9,000" 확인</li>
|
||||
<li>장바구니 바 우측 [주문하기] 클릭 → 확인 알림 → [발주]</li>
|
||||
<li>"주문 완료 — 주문번호: ORD-20260507-0001" 알림 → 확인 → [내 주문 내역]으로 자동 이동</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h4>화면이 어떻게 생겼나요?</h4>
|
||||
<div class="screen">
|
||||
@@ -377,19 +504,119 @@
|
||||
</div>
|
||||
|
||||
<!-- 가.2 주문 내역 -->
|
||||
<h3 id="u-orders">가-2. 내가 주문한 내역 보기</h3>
|
||||
<p>왼쪽 메뉴의 <b>거래처 주문 → 내 주문 내역</b> 을 누르면 보여요.</p>
|
||||
<h3 id="u-orders">가-2. 내 주문 내역 보기 + 수정·취소</h3>
|
||||
<p class="lead">왼쪽 메뉴의 <b>거래처 주문 → 내 주문 내역</b>을 누르면 내가 주문한 전체 이력을 볼 수 있어요. 출고 전인 주문은 여기서 <b>수량을 고치거나 품목을 빼거나 통째로 취소</b>할 수 있습니다.</p>
|
||||
|
||||
<h4>① 주문 이력 목록 화면</h4>
|
||||
<div class="screen">
|
||||
<div class="browser-bar"><span class="dots"><i></i><i></i><i></i></span><span class="url">momotogether.com / 내 주문 내역</span></div>
|
||||
<div class="body">
|
||||
<div class="row" style="margin-bottom:8px"><b>내 발주 이력 (전체 5건)</b><span><span class="btn btn-emerald">⬇ 엑셀</span> <span class="btn btn-emerald">새 발주</span></span></div>
|
||||
<div class="row" style="font-size:11.5px;color:#94a3b8;border:0">행을 누르면 거래명세표가 큰 창으로 떠요</div>
|
||||
<table class="demo-table" style="margin-top:6px">
|
||||
<thead><tr><th>발주번호</th><th>발주일</th><th>합계</th><th>상태</th><th>동작</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ORD-20260507-0001</td><td class="ctr">5/7</td><td class="num">27,200</td><td class="ctr"><span class="badge b-amber">출고요청</span></td><td class="ctr">👁 보기</td></tr>
|
||||
<tr><td>ORD-20260506-0003</td><td class="ctr">5/6</td><td class="num">37,200</td><td class="ctr"><span class="badge b-sky">출고완료</span></td><td class="ctr">👁 보기</td></tr>
|
||||
<tr><td>ORD-20260505-0002</td><td class="ctr">5/5</td><td class="num">120,500</td><td class="ctr"><span class="badge b-sage">입금완료</span></td><td class="ctr">👁 보기</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>주문번호 또는 [보기] 클릭</b> → 거래명세표가 큰 창으로 떠요</li>
|
||||
<li>거래명세표 위쪽에 <span class="btn btn-orange">📤 이미지 공유</span> <span class="btn btn-emerald">⬇ 엑셀 다운로드</span> 버튼이 있어 카톡 공유나 파일 저장 가능</li>
|
||||
<li><b>출고요청 상태</b>인 주문은 그 자리에서 <b>수정·삭제·취소</b>가 가능해요:
|
||||
<ul>
|
||||
<li>품목 행의 <b>수량 칸을 클릭</b>해서 새 수량 입력 → 다른 곳 누르면 자동 저장</li>
|
||||
<li>품목 오른쪽 <b>[×]</b> 버튼 → 그 품목만 주문에서 삭제</li>
|
||||
<li>위쪽 <span class="btn btn-rose">🗑 주문 취소</span> → 주문 전체 취소</li>
|
||||
<li>택배·용차 라인은 모모 담당자가 조정합니다 (직접 수정 불가)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>한 줄을 클릭하거나 [👁 보기] 버튼 → 거래명세표 모달 열림</li>
|
||||
<li>위쪽 [⬇ 엑셀] 버튼 → 전체 이력 엑셀로 받기</li>
|
||||
</ul>
|
||||
|
||||
<h4>② 거래명세표 모달 — 모양 한눈에</h4>
|
||||
<div class="screen">
|
||||
<div class="body" style="background:#f8fafc">
|
||||
<div style="background:#fff;border-radius:10px;padding:18px;max-width:680px;margin:0 auto">
|
||||
<div style="display:flex;gap:8px;justify-content:flex-start;margin-bottom:10px">
|
||||
<span class="btn btn-orange">📤 이미지 공유</span>
|
||||
<span class="btn btn-emerald">⬇ 엑셀 다운로드</span>
|
||||
<span class="btn btn-rose">🗑 주문 취소</span>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:18px;letter-spacing:.4em;font-weight:700;padding:8px 0">거 래 명 세 표</div>
|
||||
<div style="font-size:11px"><b>발주번호</b> · ORD-20260507-0001 | <b>발주일자</b> · 2026-05-07 | <b>현재상태</b> · 출고요청</div>
|
||||
<div style="margin:8px 0;padding:8px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;font-size:11px">✏️ 출고요청 상태 — 품목 수량을 직접 고치거나 [×]로 삭제할 수 있어요. 저장은 자동.</div>
|
||||
<table class="demo-table">
|
||||
<thead><tr><th>#</th><th>품명</th><th>구분</th><th>수량</th><th>단가</th><th>합계</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="r-delivery"><td class="ctr">1</td><td><span class="badge b-orange">택배</span> 택배비</td><td class="ctr">과세</td><td class="num">1</td><td class="num">4,000</td><td class="num">4,000</td><td class="ctr">자동</td></tr>
|
||||
<tr class="r-item"><td class="ctr">2</td><td>꽃계탕</td><td class="ctr" style="color:#7c3aed">면세</td><td class="num"><span style="border:1px solid #cbd5e1;padding:2px 8px;background:#fff">2</span></td><td class="num">4,500</td><td class="num">9,000</td><td class="ctr"><span style="color:#e11d48">×</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-align:right;margin-top:6px;font-size:11px"><b>총 합계 ₩ 13,000</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>③ 상태별로 할 수 있는 일 (요약표)</h4>
|
||||
<table>
|
||||
<thead><tr><th>상태</th><th>의미</th><th>할 수 있는 일</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="badge b-amber">출고요청</span></td>
|
||||
<td>주문이 들어갔고 모모 직원이 처리하기 전</td>
|
||||
<td><b>수량 수정 / 품목 삭제 / 주문 취소</b> 모두 가능. 거래명세표 이미지 공유 / 엑셀.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge b-sky">출고완료</span></td>
|
||||
<td>물건이 나갔고 거래명세표 메일이 와 있음</td>
|
||||
<td>거래명세표 이미지 공유 / 엑셀 다운로드. 수정 불가.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge b-sage">입금완료</span></td>
|
||||
<td>입금이 등록됨</td>
|
||||
<td>거래명세표 보기. 세금계산서 대기.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge b-violet">계산서발행</span></td>
|
||||
<td>전자세금계산서 발행 완료</td>
|
||||
<td>홈택스에서 사업자번호로 조회 가능.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge b-slate">취소</span></td>
|
||||
<td>주문이 취소됨</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>④ 출고요청 상태 — 수량 수정하기</h4>
|
||||
<ol class="steps">
|
||||
<li><b>거래명세표 모달 열기</b><small>[내 주문 내역]에서 출고요청 상태 행 클릭</small></li>
|
||||
<li><b>품목 행의 수량 칸 클릭</b><small>네모난 입력 칸이 활성화돼요. 새 수량을 입력하세요.</small></li>
|
||||
<li><b>다른 곳 클릭하거나 <kbd>엔터</kbd></b><small>입력 칸에서 빠져나가는 순간 자동 저장. 위쪽 합계도 즉시 갱신.</small></li>
|
||||
<li><b>재고/한도 초과 시 자동 막힘</b><small>"재고를 초과할 수 없습니다" 또는 "1회 발주 한도 초과" 알림.</small></li>
|
||||
</ol>
|
||||
|
||||
<h4>⑤ 출고요청 상태 — 품목 1개만 빼기</h4>
|
||||
<ol class="steps">
|
||||
<li><b>품목 행 우측 끝의 [×] 버튼 클릭</b></li>
|
||||
<li><b>"이 품목을 주문에서 삭제하시겠습니까?" 확인</b><small>[삭제] 누르면 그 품목만 빠져요. 다른 품목과 택배/용차는 그대로.</small></li>
|
||||
</ol>
|
||||
|
||||
<h4>⑥ 출고요청 상태 — 주문 전체 취소</h4>
|
||||
<ol class="steps">
|
||||
<li><b>모달 위쪽 [🗑 주문 취소] 버튼 클릭</b></li>
|
||||
<li><b>"주문을 취소하시겠습니까?" 확인 → [취소]</b><small>주문 상태가 <span class="badge b-slate">취소</span>로 변하고 더는 처리되지 않습니다.</small></li>
|
||||
</ol>
|
||||
|
||||
<div class="warn">
|
||||
<b>⚠️ 알아두세요</b>
|
||||
<ul style="margin:6px 0 0;padding-left:20px">
|
||||
<li>택배비·용차비 줄은 거래처가 못 고쳐요. 모모 담당자가 출고할 때 조정합니다.</li>
|
||||
<li>출고완료 상태가 되면 <b>수정·취소 불가</b>. 잘못 보냈으면 모모 담당자에게 직접 연락하세요.</li>
|
||||
<li>가격·단가는 시스템 표준값으로 자동 계산돼요 (변경 불가).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h4>⑦ 거래명세표 공유 / 엑셀</h4>
|
||||
<ul>
|
||||
<li><span class="btn btn-orange">📤 이미지 공유</span> — 거래명세표를 PNG 이미지로 만들어서 카톡으로 공유하거나 휴대폰에 저장. 휴대폰이면 카톡 선택 창이 뜨고, 컴퓨터면 이미지 파일이 다운로드 폴더에 저장돼요.</li>
|
||||
<li><span class="btn btn-emerald">⬇ 엑셀 다운로드</span> — 거래명세표를 엑셀 파일(.xlsx)로 저장. 인쇄나 보관용.</li>
|
||||
</ul>
|
||||
<h4>주문 상태별 뜻</h4>
|
||||
<table>
|
||||
@@ -580,17 +807,69 @@
|
||||
</div>
|
||||
|
||||
<!-- 다.2 -->
|
||||
<h3 id="i-inbound">다-2. 물건 들어오면 등록하기 (창고에 쌓임)</h3>
|
||||
<p class="lead">물건이 창고에 도착하면 이 화면에서 등록해요. 등록하면 <b>창고 재고가 늘어나요(+)</b>.</p>
|
||||
<h3 id="i-inbound">다-2. 입고 처리 — 발주 선택 후 라인별 입고</h3>
|
||||
<p class="lead">물건이 창고에 도착하면 이 화면에서 입고 등록 → <b>재고가 자동으로 증가</b>합니다. 발주 한 번 했지만 일부만 들어오는 경우 — 들어온 만큼만 입력하고 나머지는 다음에 마저 입고할 수 있어요.</p>
|
||||
|
||||
<h4>화면 구성 — 좌-우 분할 (발주 선택 → 라인별 입력)</h4>
|
||||
<div class="screen">
|
||||
<div class="browser-bar"><span class="dots"><i></i><i></i><i></i></span><span class="url">momotogether.com / 입고 처리</span></div>
|
||||
<div class="body" style="display:grid;grid-template-columns:330px 1fr;gap:12px">
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:11px;color:#475569;margin-bottom:6px">발주서 목록 (입고 가능)</div>
|
||||
<table class="demo-table">
|
||||
<thead><tr><th>발주번호</th><th>공급업체</th><th>발주/입고/미입고</th><th>상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr style="background:#fffbeb"><td>PRC-20260507-0001</td><td>(주)AVATEC</td><td class="ctr">10 / 0 / 10</td><td class="ctr"><span class="badge b-amber">발주요청</span></td></tr>
|
||||
<tr><td>PRC-20260506-0002</td><td>제일상사</td><td class="ctr">20 / <b style="color:#0f766e">15</b> / <b style="color:#e11d48">5</b></td><td class="ctr"><span class="badge b-orange">입고중</span></td></tr>
|
||||
<tr><td>PRC-20260505-0003</td><td>(주)AVATEC</td><td class="ctr">8 / 8 / 0</td><td class="ctr"><span class="badge b-sage">입고완료</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:11px;color:#475569;margin-bottom:6px">입고 입력 — PRC-20260506-0002 <span class="btn btn-emerald" style="float:right">💾 입고 등록</span></div>
|
||||
<table class="demo-table">
|
||||
<thead><tr><th>#</th><th>품목</th><th>발주</th><th>기입고</th><th>남은</th><th>창고</th><th>정상 입고</th><th>불량</th></tr></thead>
|
||||
<tbody>
|
||||
<tr style="background:#ecfdf5"><td class="ctr">1</td><td>꽃계탕 ✓ 완료</td><td class="num">10</td><td class="num" style="color:#0f766e">10</td><td class="num" style="color:#94a3b8">0</td><td class="ctr">-</td><td class="ctr">-</td><td class="ctr">-</td></tr>
|
||||
<tr><td class="ctr">2</td><td>탈취제</td><td class="num">10</td><td class="num" style="color:#0f766e">5</td><td class="num" style="color:#e11d48"><b>5</b></td><td class="ctr">[본사창고 ▾]</td><td class="num">[5]</td><td class="num">[0]</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>입고 처리 단계</h4>
|
||||
<ol class="steps">
|
||||
<li><b>입고할 매입 발주 고르기</b><small>아직 입고 안 된 발주서 목록이 떠요</small></li>
|
||||
<li><b>입고 개수 / 창고 / 입고일 적기</b><small>주문할 때 적은 개수랑 실제 들어온 개수가 다를 수 있어요. 부분 입고도 가능해요.</small></li>
|
||||
<li><b>[입고 등록] 누르기</b><small>해당 창고의 재고가 N개만큼 늘어나요. 누가 언제 무엇을 입고했는지 기록도 남아요.</small></li>
|
||||
<li><b>왼쪽에서 발주서 선택</b><small>'입고 가능 (발주요청 + 입고중)' 필터가 기본. 한 줄 클릭하면 오른쪽에 라인이 펼쳐져요. <span class="badge b-sage">입고완료</span>는 더 이상 입력 안 됨.</small></li>
|
||||
<li><b>발주/입고/미입고 숫자 확인</b><small>예: <b>10 / 5 / 5</b> = 발주 10개, 이미 5개 입고됨, 남은 5개. 이 발주는 <span class="badge b-orange">입고중</span> 상태.</small></li>
|
||||
<li><b>각 라인의 [창고 선택] + [정상 입고] 수량 입력</b><small>창고는 [창고 관리]에 미리 등록한 곳에서 선택. 정상 입고는 <b>남은 수량 이하</b>로만 입력 가능 (자동 클램프). 불량 있으면 [불량] 칸에도 적기.</small></li>
|
||||
<li><b>입고 안 할 라인은 0으로 두기</b><small>완전히 입고된 라인(남은 0)은 자동으로 <b>✓ 완료</b> 표시되고 입력 칸이 사라져요.</small></li>
|
||||
<li><b>위쪽 [💾 입고 등록] 클릭</b><small>"입고 처리하시겠습니까?" 확인 → [입고]. 누르는 즉시:<br>① 정상 입고 수량만큼 창고 재고 자동 증가<br>② 발주서 라인의 '기입고' 누적<br>③ 모든 라인 완전 입고면 발주서 상태 → <span class="badge b-sage">입고완료</span><br>④ 일부만 입고면 발주서 상태 → <span class="badge b-orange">입고중</span> (다음에 다시 와서 마저 입고)</small></li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<b>💡 부분 입고 시나리오</b>
|
||||
<p>월요일 발주 10개 → 화요일 5개만 도착했다면:</p>
|
||||
<ol style="padding-left:22px;margin:4px 0 0">
|
||||
<li>화요일: 발주서 선택 → 정상 입고에 5 입력 → [입고 등록] → 발주서 상태 <span class="badge b-orange">입고중</span></li>
|
||||
<li>목요일 나머지 5개 도착: 같은 발주서를 다시 선택 → 정상 입고에 5 입력 → [입고 등록] → 발주서 상태 <span class="badge b-sage">입고완료</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h4>발주/입고/미입고 표시 의미</h4>
|
||||
<table>
|
||||
<thead><tr><th width="180">표시</th><th>의미</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>10 / 0 / 10</b></td><td>발주만 했고 입고는 아직. 상태 <span class="badge b-amber">발주요청</span></td></tr>
|
||||
<tr><td><b>10 / 5 / 5</b></td><td>일부만 입고. 상태 <span class="badge b-orange">입고중</span> — 5개 더 들어와야 함</td></tr>
|
||||
<tr><td><b>10 / 10 / 0</b></td><td>전부 입고 완료. 상태 <span class="badge b-sage">입고완료</span> — 더 이상 입고 입력 불가</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="tip">
|
||||
<b>💡 입고 vs 출고 — 헷갈리지 마세요</b>
|
||||
<ul style="margin:6px 0 0;padding-left:24px">
|
||||
<li><b>입고</b>: 도매처 → 모모 창고로 들어옴 → 재고 <b>+</b></li>
|
||||
<li><b>입고</b>: 공급업체 → 모모 창고로 들어옴 → 재고 <b>+</b></li>
|
||||
<li><b>출고</b>: 모모 창고 → 가게(거래처)로 나감 → 재고 <b>−</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Save, RefreshCcw, CheckCircle2, Clock } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Inbound { OBJID: string; INBOUND_NO: string; INBOUND_DATE: string; VENDOR_NAME: string; WH_NAME: string; PROC_NO: string; STATUS: string; QTY_NORMAL: number; QTY_DEFECT: number; TOTAL_AMOUNT: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
interface ProcRow {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number;
|
||||
TOTAL_QTY: number; RECEIVED_QTY: number;
|
||||
}
|
||||
interface ProcDetail { OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string; VENDOR_NAME: string | null }
|
||||
interface ProcLine {
|
||||
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
UNIT: string; QTY: number; COST_PRICE: number;
|
||||
RECEIVED_QTY: number; RECEIVED_NORMAL: number; RECEIVED_DEFECT: number;
|
||||
}
|
||||
interface Warehouse { OBJID: string; WH_NAME: string }
|
||||
|
||||
const fmt = (n: number | string | undefined) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
PARTIAL: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
|
||||
};
|
||||
|
||||
export default function InboundsPage() {
|
||||
const [list, setList] = useState<Inbound[]>([]);
|
||||
const [list, setList] = useState<ProcRow[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("OPEN_OR_PARTIAL");
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/inbounds/list", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
// 라인별 입력 (창고/입고수량/불량수량)
|
||||
const [inputs, setInputs] = useState<Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const body: Record<string, unknown> = {};
|
||||
// 입고 화면은 REQUESTED + PARTIAL 만 보이게
|
||||
const res = await fetch("/api/m/procurements/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json();
|
||||
let rows: ProcRow[] = j.RESULTLIST ?? [];
|
||||
if (statusFilter === "OPEN_OR_PARTIAL") {
|
||||
rows = rows.filter((r) => r.STATUS === "REQUESTED" || r.STATUS === "PARTIAL");
|
||||
} else if (statusFilter && statusFilter !== "ALL") {
|
||||
rows = rows.filter((r) => r.STATUS === statusFilter);
|
||||
}
|
||||
setList(rows);
|
||||
if (rows.length && !rows.some((r) => r.OBJID === activeId)) setActiveId(rows[0].OBJID);
|
||||
if (!rows.length) { setActiveId(""); setDetail(null); }
|
||||
}, [statusFilter, activeId]);
|
||||
|
||||
const loadWh = async () => {
|
||||
const r = await fetch("/api/m/warehouses/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
setWarehouses((await r.json()).RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!activeId) { setDetail(null); return; }
|
||||
const res = await fetch("/api/m/procurements/detail", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: activeId }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
setDetail({ proc: j.proc, items: j.items });
|
||||
// 입력 칸 초기화 — 창고는 첫 창고 / 수량은 남은 만큼
|
||||
const next: Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }> = {};
|
||||
const defaultWh = warehouses[0]?.OBJID ?? "";
|
||||
for (const it of j.items) {
|
||||
const remaining = Number(it.QTY) - Number(it.RECEIVED_QTY ?? 0);
|
||||
next[it.OBJID] = { whObjid: defaultWh, qtyNormal: Math.max(0, remaining), qtyDefect: 0 };
|
||||
}
|
||||
setInputs(next);
|
||||
}
|
||||
}, [activeId, warehouses]);
|
||||
|
||||
useEffect(() => { loadWh(); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadDetail(); }, [loadDetail]);
|
||||
|
||||
const updateInput = (lineObjid: string, patch: Partial<{ whObjid: string; qtyNormal: number; qtyDefect: number }>) => {
|
||||
setInputs((p) => ({ ...p, [lineObjid]: { ...p[lineObjid], ...patch } }));
|
||||
};
|
||||
|
||||
const submitInbound = async () => {
|
||||
if (!detail) return;
|
||||
|
||||
// 모든 라인에 동일한 창고를 사용한다고 가정 (라인별 다른 창고 가능하게도 만들 수 있음 — 단, save API 는 단일 whObjid 받음)
|
||||
// 라인별 창고가 다르면 여러 번 호출. 여기서는 단순화: 첫 라인의 창고를 대표 창고로.
|
||||
const linesToSend = detail.items
|
||||
.map((it) => {
|
||||
const inp = inputs[it.OBJID];
|
||||
if (!inp) return null;
|
||||
const qN = Number(inp.qtyNormal) || 0;
|
||||
const qD = Number(inp.qtyDefect) || 0;
|
||||
if (qN + qD === 0) return null;
|
||||
const remaining = Number(it.QTY) - Number(it.RECEIVED_QTY ?? 0);
|
||||
if (qN + qD > remaining) return { error: `${it.ITEM_NAME} — 남은 수량(${remaining}) 초과` };
|
||||
return {
|
||||
itemObjid: it.ITEM_OBJID,
|
||||
qtyNormal: qN,
|
||||
qtyDefect: qD,
|
||||
costPrice: Number(it.COST_PRICE),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ({ itemObjid: string; qtyNormal: number; qtyDefect: number; costPrice: number } | { error: string })[];
|
||||
|
||||
const errLine = linesToSend.find((l) => "error" in l);
|
||||
if (errLine) {
|
||||
Swal.fire({ icon: "warning", title: (errLine as { error: string }).error });
|
||||
return;
|
||||
}
|
||||
const cleanLines = linesToSend.filter((l): l is { itemObjid: string; qtyNormal: number; qtyDefect: number; costPrice: number } => !("error" in l));
|
||||
if (cleanLines.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "입고할 수량을 입력하세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
// 라인별 창고가 다를 수 있으니 창고별로 그룹핑
|
||||
const byWh = new Map<string, typeof cleanLines>();
|
||||
for (const ln of cleanLines) {
|
||||
const lineDef = detail.items.find((it) => it.ITEM_OBJID === ln.itemObjid);
|
||||
const inp = inputs[lineDef?.OBJID ?? ""];
|
||||
const wh = inp?.whObjid;
|
||||
if (!wh) {
|
||||
Swal.fire({ icon: "warning", title: `${lineDef?.ITEM_NAME} — 창고를 선택하세요.` });
|
||||
return;
|
||||
}
|
||||
if (!byWh.has(wh)) byWh.set(wh, []);
|
||||
byWh.get(wh)!.push(ln);
|
||||
}
|
||||
|
||||
const ok = await Swal.fire({
|
||||
icon: "question", title: "입고 처리하시겠습니까?",
|
||||
html: `매입발주 <b>${detail.proc.PROC_NO}</b><br>총 ${cleanLines.length}개 라인 입고`,
|
||||
showCancelButton: true, confirmButtonText: "입고", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
|
||||
setBusy(true);
|
||||
let successCnt = 0, failCnt = 0;
|
||||
const errors: string[] = [];
|
||||
for (const [whObjid, whLines] of byWh.entries()) {
|
||||
try {
|
||||
const res = await fetch("/api/m/inbounds/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
procObjid: detail.proc.OBJID,
|
||||
whObjid,
|
||||
lines: whLines,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) successCnt++;
|
||||
else { failCnt++; errors.push(j.message); }
|
||||
} catch (err) {
|
||||
failCnt++;
|
||||
errors.push(err instanceof Error ? err.message : "오류");
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
|
||||
Swal.fire({
|
||||
icon: failCnt === 0 ? "success" : "warning",
|
||||
title: `입고 처리 완료 (성공 ${successCnt} / 실패 ${failCnt})`,
|
||||
html: errors.length > 0 ? errors.join("<br>") : "재고가 자동으로 늘어났습니다.",
|
||||
});
|
||||
load();
|
||||
loadDetail();
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">입고 처리</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">매입발주 후 도매처에서 받은 물품을 창고에 입고. 정상/불량 분리 기록.</p>
|
||||
<h1 className="text-xl font-bold">입고 처리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 발주서를 선택하고, 오른쪽에서 라인별 창고/입고수량을 입력하세요. 부분 입고 가능 — 발주수량까지만.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
<option value="OPEN_OR_PARTIAL">입고 가능 (발주요청 + 입고중)</option>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="REQUESTED">발주요청만</option>
|
||||
<option value="PARTIAL">입고중만</option>
|
||||
<option value="RECEIVED">입고완료</option>
|
||||
</select>
|
||||
<button onClick={load} className="h-9 px-3 rounded bg-white border border-slate-300 text-sm font-semibold inline-flex items-center gap-1">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/m/admin/inbounds/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
<Plus size={16} /> 입고 처리
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">입고번호</th>
|
||||
<th className="text-left px-4 py-3">입고일</th>
|
||||
<th className="text-left px-4 py-3">공급업체</th>
|
||||
<th className="text-left px-4 py-3">창고</th>
|
||||
<th className="text-left px-4 py-3">매입발주</th>
|
||||
<th className="text-right px-4 py-3">정상</th>
|
||||
<th className="text-right px-4 py-3">불량</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">입고 이력이 없습니다.</td></tr>
|
||||
) : list.map((b) => (
|
||||
<tr key={b.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{b.INBOUND_NO}</td>
|
||||
<td className="px-4 py-3">{b.INBOUND_DATE}</td>
|
||||
<td className="px-4 py-3">{b.VENDOR_NAME || "-"}</td>
|
||||
<td className="px-4 py-3">{b.WH_NAME}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{b.PROC_NO || "-"}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-emerald-700 font-semibold">{fmt(b.QTY_NORMAL)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-rose-600 font-semibold">{fmt(b.QTY_DEFECT)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(b.TOTAL_AMOUNT)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-3">
|
||||
{/* 좌: 매입 발주 리스트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
발주서 목록 ({list.length}건)
|
||||
</div>
|
||||
<div className="overflow-x-auto max-h-[calc(100vh-220px)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-2">발주번호</th>
|
||||
<th className="text-left px-2 py-2">공급업체</th>
|
||||
<th className="text-center px-2 py-2">발주/입고/미입고</th>
|
||||
<th className="text-center px-2 py-2">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={4} className="text-center py-12 text-slate-400">입고 가능한 발주서가 없습니다.</td></tr>
|
||||
) : list.map((p) => {
|
||||
const total = Number(p.TOTAL_QTY);
|
||||
const recv = Number(p.RECEIVED_QTY);
|
||||
const remain = Math.max(0, total - recv);
|
||||
return (
|
||||
<tr key={p.OBJID}
|
||||
onClick={() => setActiveId(p.OBJID)}
|
||||
style={{ cursor: "pointer" }}
|
||||
className={`border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}>
|
||||
<td className="px-2 py-2 font-semibold">{p.PROC_NO}<div className="text-slate-400 text-[10px]">{p.PROC_DATE}</div></td>
|
||||
<td className="px-2 py-2 truncate max-w-[120px]">{p.VENDOR_NAME ?? <span className="text-slate-300">미선택</span>}</td>
|
||||
<td className="px-2 py-2 text-center text-[11px] tabular-nums">
|
||||
<span className="text-slate-700">{fmt(total)}</span>
|
||||
<span className="text-slate-300 mx-0.5">/</span>
|
||||
<span className="text-emerald-700 font-semibold">{fmt(recv)}</span>
|
||||
{remain > 0 && (<>
|
||||
<span className="text-slate-300 mx-0.5">/</span>
|
||||
<span className="text-rose-600 font-semibold">{fmt(remain)}</span>
|
||||
</>)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
|
||||
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우: 입고 입력 폼 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>입고 처리 입력</span>
|
||||
{detail && (detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL") && (
|
||||
<button onClick={submitInbound} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Save size={12} /> 입고 등록
|
||||
</button>
|
||||
)}
|
||||
{detail && detail.proc.STATUS === "RECEIVED" && (
|
||||
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> 입고 완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{!detail ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">왼쪽에서 매입 발주서를 선택하세요.</div>
|
||||
) : (
|
||||
<InboundForm
|
||||
detail={detail}
|
||||
warehouses={warehouses}
|
||||
inputs={inputs}
|
||||
onUpdate={updateInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboundForm({ detail, warehouses, inputs, onUpdate }: {
|
||||
detail: { proc: ProcDetail; items: ProcLine[] };
|
||||
warehouses: Warehouse[];
|
||||
inputs: Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>;
|
||||
onUpdate: (lineObjid: string, patch: Partial<{ whObjid: string; qtyNormal: number; qtyDefect: number }>) => void;
|
||||
}) {
|
||||
const editable = detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL";
|
||||
return (
|
||||
<div className="text-[12px]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-base font-bold">{detail.proc.PROC_NO}</div>
|
||||
<div className="text-xs text-slate-500">발주일 {detail.proc.PROC_DATE} · 공급업체 {detail.proc.VENDOR_NAME ?? "-"}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded border ${STATUS_COLOR[detail.proc.STATUS]}`}>
|
||||
{detail.proc.STATUS === "PARTIAL" ? <Clock size={12} /> : detail.proc.STATUS === "RECEIVED" ? <CheckCircle2 size={12} /> : null}
|
||||
{STATUS_LABEL[detail.proc.STATUS] ?? detail.proc.STATUS}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!editable && (
|
||||
<div className="border border-slate-200 bg-slate-50 rounded p-2 text-[11px] text-slate-600 mb-2">
|
||||
이 발주서는 {STATUS_LABEL[detail.proc.STATUS]} 상태라 입고 입력이 불가합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="w-full text-[11px] border border-slate-300" style={{borderCollapse:'collapse'}}>
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-8">#</th>
|
||||
<th className="border border-slate-300 px-2 py-1.5 text-left">품목</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-16">발주</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-16">기입고</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-16">남은</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-32">창고</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-20">정상 입고</th>
|
||||
<th className="border border-slate-300 px-1 py-1.5 w-20">불량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{detail.items.length === 0 ? (
|
||||
<tr><td colSpan={8} className="border border-slate-300 px-2 py-6 text-center text-slate-400">발주 라인이 없습니다.</td></tr>
|
||||
) : detail.items.map((it, idx) => {
|
||||
const total = Number(it.QTY);
|
||||
const recv = Number(it.RECEIVED_QTY ?? 0);
|
||||
const remain = Math.max(0, total - recv);
|
||||
const inp = inputs[it.OBJID] ?? { whObjid: "", qtyNormal: 0, qtyDefect: 0 };
|
||||
const fullyReceived = remain === 0;
|
||||
return (
|
||||
<tr key={it.OBJID} className={fullyReceived ? "bg-emerald-50/40" : "bg-white"}>
|
||||
<td className="border border-slate-300 px-1 py-1 text-center">{idx + 1}</td>
|
||||
<td className="border border-slate-300 px-2 py-1">
|
||||
{it.ITEM_NAME} <span className="text-slate-400">[{it.ITEM_CODE}]</span>
|
||||
{fullyReceived && <span className="ml-2 text-[10px] text-emerald-700 font-bold">✓ 완료</span>}
|
||||
</td>
|
||||
<td className="border border-slate-300 px-1 py-1 text-right">{fmt(total)}</td>
|
||||
<td className="border border-slate-300 px-1 py-1 text-right text-emerald-700">{fmt(recv)}</td>
|
||||
<td className={`border border-slate-300 px-1 py-1 text-right ${remain > 0 ? "text-rose-600 font-bold" : "text-slate-400"}`}>{fmt(remain)}</td>
|
||||
<td className="border border-slate-300 px-1 py-1">
|
||||
{editable && !fullyReceived ? (
|
||||
<select
|
||||
value={inp.whObjid}
|
||||
onChange={(e) => onUpdate(it.OBJID, { whObjid: e.target.value })}
|
||||
className="w-full h-7 px-1 border border-slate-200 rounded text-[11px] bg-white">
|
||||
<option value="">-- 선택 --</option>
|
||||
{warehouses.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
) : <span className="text-slate-400">-</span>}
|
||||
</td>
|
||||
<td className="border border-slate-300 px-1 py-1">
|
||||
{editable && !fullyReceived ? (
|
||||
<input type="number" min={0} max={remain} value={inp.qtyNormal}
|
||||
onChange={(e) => {
|
||||
const v = Math.min(remain, Math.max(0, Number(e.target.value) || 0));
|
||||
onUpdate(it.OBJID, { qtyNormal: v });
|
||||
}}
|
||||
className="w-full h-7 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
|
||||
) : <span className="text-slate-400">-</span>}
|
||||
</td>
|
||||
<td className="border border-slate-300 px-1 py-1">
|
||||
{editable && !fullyReceived ? (
|
||||
<input type="number" min={0} max={remain} value={inp.qtyDefect}
|
||||
onChange={(e) => {
|
||||
const v = Math.min(remain, Math.max(0, Number(e.target.value) || 0));
|
||||
onUpdate(it.OBJID, { qtyDefect: v });
|
||||
}}
|
||||
className="w-full h-7 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
|
||||
) : <span className="text-slate-400">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{editable && (
|
||||
<div className="mt-3 text-[11px] text-slate-500">
|
||||
※ 정상 입고 + 불량은 <b>남은 수량 이하</b>로만 입력 가능합니다. 0으로 두면 그 라인은 입고하지 않습니다.<br />
|
||||
※ 일부 라인만 입고하면 발주서가 <span className="inline-block px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">입고중</span>으로 표시되고, 나중에 다시 들어와 마저 입고할 수 있어요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,11 +39,35 @@ export async function POST(req: NextRequest) {
|
||||
[inboundObjid, inboundNo, procObjid ?? null, vendorObjid ?? null, whObjid, inboundDate ?? null, total, memo ?? null, adminId]
|
||||
);
|
||||
|
||||
// 입고 한도 사전 검증 (procObjid 있을 때) — 발주수량 - 기존 누적 입고 ≥ 이번 입고
|
||||
if (procObjid) {
|
||||
for (const ln of lines) {
|
||||
const qtyN = Number(ln.qtyNormal) || 0;
|
||||
const qtyD = Number(ln.qtyDefect) || 0;
|
||||
const cur = await client.query(
|
||||
`SELECT qty, COALESCE(received_qty,0) AS received, item_objid
|
||||
FROM momo_procurement_items
|
||||
WHERE proc_objid = $1 AND item_objid = $2`,
|
||||
[procObjid, ln.itemObjid]
|
||||
);
|
||||
if (cur.rowCount === 0) continue;
|
||||
const remaining = Number(cur.rows[0].qty) - Number(cur.rows[0].received);
|
||||
if (qtyN + qtyD > remaining) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `입고 가능 수량을 초과했습니다. (남은 수량 ${remaining})`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
for (const ln of lines) {
|
||||
seq++;
|
||||
const qtyN = Number(ln.qtyNormal) || 0;
|
||||
const qtyD = Number(ln.qtyDefect) || 0;
|
||||
if (qtyN + qtyD === 0) continue; // 0 입고 라인은 건너뛰기
|
||||
const lineTotal = Math.round(Number(ln.costPrice) * (qtyN + qtyD));
|
||||
await client.query(
|
||||
`INSERT INTO momo_inbound_items (objid, inbound_objid, item_objid, qty_normal, qty_defect, cost_price, defect_reason, total_amount, seq)
|
||||
@@ -51,7 +75,6 @@ export async function POST(req: NextRequest) {
|
||||
[createObjectId(), inboundObjid, ln.itemObjid, qtyN, qtyD, ln.costPrice, ln.defectReason ?? null, lineTotal, seq]
|
||||
);
|
||||
|
||||
// 정상 수량만 재고 +
|
||||
if (qtyN > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||
@@ -65,7 +88,6 @@ export async function POST(req: NextRequest) {
|
||||
[createObjectId(), whObjid, ln.itemObjid, qtyN, inboundObjid, adminId]
|
||||
);
|
||||
}
|
||||
// 매입발주 라인 received 누적
|
||||
if (procObjid) {
|
||||
await client.query(
|
||||
`UPDATE momo_procurement_items
|
||||
@@ -76,21 +98,27 @@ export async function POST(req: NextRequest) {
|
||||
[procObjid, qtyN, qtyD, ln.itemObjid]
|
||||
);
|
||||
}
|
||||
// 매입가 갱신 (선택)
|
||||
if (Number(ln.costPrice) > 0) {
|
||||
await client.query(`UPDATE momo_items SET cost_price = $2 WHERE objid = $1`, [ln.itemObjid, ln.costPrice]);
|
||||
}
|
||||
}
|
||||
|
||||
// 매입발주 모든 라인이 received_qty >= qty 면 status=RECEIVED
|
||||
// 매입발주 상태 갱신: 모두 입고 완료면 RECEIVED, 일부만이면 PARTIAL
|
||||
if (procObjid) {
|
||||
const remain = await client.query(
|
||||
`SELECT COUNT(*) AS cnt FROM momo_procurement_items
|
||||
WHERE proc_objid = $1 AND COALESCE(received_qty,0) < qty`,
|
||||
const status = await client.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) < qty) AS pending,
|
||||
COUNT(*) FILTER (WHERE COALESCE(received_qty,0) > 0) AS started
|
||||
FROM momo_procurement_items
|
||||
WHERE proc_objid = $1`,
|
||||
[procObjid]
|
||||
);
|
||||
if (Number(remain.rows[0].cnt) === 0) {
|
||||
const pending = Number(status.rows[0].pending);
|
||||
const started = Number(status.rows[0].started);
|
||||
if (pending === 0) {
|
||||
await client.query(`UPDATE momo_procurements SET status='RECEIVED' WHERE objid=$1`, [procObjid]);
|
||||
} else if (started > 0) {
|
||||
await client.query(`UPDATE momo_procurements SET status='PARTIAL' WHERE objid=$1`, [procObjid]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export async function POST(req: NextRequest) {
|
||||
TO_CHAR(P.proc_date,'YYYY-MM-DD') AS "PROC_DATE",
|
||||
P.vendor_objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME",
|
||||
P.status AS "STATUS", P.total_amount AS "TOTAL_AMOUNT", P.memo AS "MEMO",
|
||||
(SELECT COUNT(*) FROM momo_procurement_items WHERE proc_objid = P.objid) AS "LINE_CNT"
|
||||
(SELECT COUNT(*) FROM momo_procurement_items WHERE proc_objid = P.objid) AS "LINE_CNT",
|
||||
COALESCE((SELECT SUM(qty) FROM momo_procurement_items WHERE proc_objid = P.objid), 0) AS "TOTAL_QTY",
|
||||
COALESCE((SELECT SUM(received_qty) FROM momo_procurement_items WHERE proc_objid = P.objid), 0) AS "RECEIVED_QTY"
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text
|
||||
WHERE ${conds.join(" AND ")}
|
||||
|
||||
Reference in New Issue
Block a user