feat(입고 처리): 매입발주 선택 → 라인별 창고/수량 입고 (부분/전체) + 매뉴얼 보강
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:
chpark
2026-05-08 01:16:29 +09:00
parent da9b16f012
commit 88e7eab65e
4 changed files with 716 additions and 77 deletions
+298 -19
View File
@@ -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>전체 / 면세 / 과세 ▾ &nbsp;<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> &nbsp; <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>
<li>한 줄을 클릭하거나 [👁 보기] 버튼 → 거래명세표 모달 열림</li>
<li>위쪽 [⬇ 엑셀] 버튼 → 전체 이력 엑셀로 받기</li>
</ul>
</li>
<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">&nbsp;&nbsp;&nbsp;&nbsp;</div>
<div style="font-size:11px"><b>발주번호</b> · ORD-20260507-0001 &nbsp;&nbsp; <b>발주일자</b> · 2026-05-07 &nbsp;&nbsp; <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>
+369 -39
View File
@@ -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>
<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 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>
</div>
<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">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<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-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>
<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={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><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>
);
}
+36 -8
View File
@@ -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]);
}
}
+3 -1
View File
@@ -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 ")}