diff --git a/public/manual.html b/public/manual.html index c7372de..89f6cb8 100644 --- a/public/manual.html +++ b/public/manual.html @@ -294,8 +294,135 @@

가게 사장님이 모모유통에서 물건을 주문할 때 쓰는 화면이에요. 컴퓨터로도 핸드폰으로도 잘 보입니다.

-

가-1. 물건 주문하기 — 가장 많이 쓰는 화면

-

로그인하면 자동으로 이 화면이 열려요.

+

가-1. 물건 주문하기 — 한 번에 따라하기

+

가게(거래처) 사장님이 모모유통에 물건을 주문하는 화면입니다. 로그인하면 자동으로 이 화면이 열려요. 컴퓨터·휴대폰 어디서든 잘 보여요.

+ +

① 로그인 후 첫 화면 — 주문 화면

+
+
momotogether.com
+
📦 모모유통👤 우리가게 · 로그아웃
+
+
+
+ 🛒 장바구니 0 + 주문하기 +
+
+

▲ 처음에는 비어있어요. 아래에서 물건을 담으면 숫자가 올라가요.

+
🔍 검색창에 물건 이름·코드 입력전체 / 면세 / 과세 ▾  검색
+
+
+ +

② 물건 검색해서 카드 보기

+

"꽃계탕"이라고 검색창에 치고 [검색] 또는 엔터를 누르면 아래에 물건 카드가 나와요. 카드는 사진·이름·가격·재고·뱃지 모양으로 보여요.

+
+
+
+
+ 📦
+ 꽃계탕 면세
+ 제조사명
+ 4,500원
+ 창고에 212개 있음
+ + 담기 +
+
+ 📦
+ 김치찌개 택배만
+ 택배 전용 물건
+ 12,000원
+ 창고에 50개 있음
+ + 담기 +
+
+ 📦
+ 참치캔
+ 품절
+ 창고에 0개
+ + 담기 +
+
+
+
+

카드의 표시들 :

+ + +

③ [+ 담기] 눌러서 장바구니에 넣기

+

버튼을 한 번 누르면 1개 들어가요. 같은 카드 [+ 담기]를 또 누르면 2개로 늘어요. 다음 같은 토스트가 우상단에 잠깐 뜨면 추가 성공:

+
✅ 장바구니에 추가됨: 꽃계탕
+

위쪽 초록색 장바구니 바의 숫자가 0 → 1 → 2 로 올라가는 게 보일 거예요.

+ +

④ 장바구니 펼쳐서 개수 조절 / 택배·용차 추가

+

위쪽 장바구니 바를 클릭하면 펼쳐져요. 담은 물건 줄과 함께 [+ 택배 추가] [+ 용차 추가] 버튼이 보입니다.

+
+
+
+
+ + 택배 추가 + + 용차 추가 + 전체 삭제 +
+
+ 택배 + 담당자/메모 + 4000×1= + ₩4,000 + × +
+
+ 꽃계탕 +
+ + 2 + + ₩9,000 + × +
+
+
+
+
+ + +

⑤ [주문하기] 눌러서 주문 확정

+

장바구니 바 우측 [주문하기] 버튼을 클릭하면 확인 알림이 떠요:

+
+ 발주를 요청하시겠습니까?
+ 합계 ₩27,200 (품목 2, 부가 1)
+ 발주   취소 +
+

[발주] 누르면 끝. 주문 번호가 만들어지고 (예: ORD-20260507-0001) 자동으로 [내 주문 내역] 화면으로 이동합니다.

+ +
+ ⚠️ 주의 + +
+ +
+

📘 처음부터 끝까지 따라하기 — "꽃계탕 2개 주문"

+
    +
  1. 로그인 → 자동으로 주문 화면이 열림
  2. +
  3. 검색창에 "꽃계탕" 치고 엔터
  4. +
  5. 꽃계탕 카드의 [+ 담기]를 두 번 클릭 → 우상단 토스트 두 번 뜸
  6. +
  7. 위쪽 초록색 장바구니 바 숫자가 1 → 2 로 변함
  8. +
  9. 장바구니 바 클릭 → 펼쳐서 "꽃계탕 2개 ₩9,000" 확인
  10. +
  11. 장바구니 바 우측 [주문하기] 클릭 → 확인 알림 → [발주]
  12. +
  13. "주문 완료 — 주문번호: ORD-20260507-0001" 알림 → 확인 → [내 주문 내역]으로 자동 이동
  14. +
+

화면이 어떻게 생겼나요?

@@ -377,19 +504,119 @@
-

가-2. 내가 주문한 내역 보기

-

왼쪽 메뉴의 거래처 주문 → 내 주문 내역 을 누르면 보여요.

+

가-2. 내 주문 내역 보기 + 수정·취소

+

왼쪽 메뉴의 거래처 주문 → 내 주문 내역을 누르면 내가 주문한 전체 이력을 볼 수 있어요. 출고 전인 주문은 여기서 수량을 고치거나 품목을 빼거나 통째로 취소할 수 있습니다.

+ +

① 주문 이력 목록 화면

+
+
momotogether.com / 내 주문 내역
+
+
내 발주 이력 (전체 5건)⬇ 엑셀 새 발주
+
행을 누르면 거래명세표가 큰 창으로 떠요
+ + + + + + + +
발주번호발주일합계상태동작
ORD-20260507-00015/727,200출고요청👁 보기
ORD-20260506-00035/637,200출고완료👁 보기
ORD-20260505-00025/5120,500입금완료👁 보기
+
+
+ +

② 거래명세표 모달 — 모양 한눈에

+
+
+
+
+ 📤 이미지 공유 + ⬇ 엑셀 다운로드 + 🗑 주문 취소 +
+
거 래 명 세 표
+
발주번호 · ORD-20260507-0001  |  발주일자 · 2026-05-07  |  현재상태 · 출고요청
+
✏️ 출고요청 상태 — 품목 수량을 직접 고치거나 [×]로 삭제할 수 있어요. 저장은 자동.
+ + + + + + +
#품명구분수량단가합계
1택배 택배비과세14,0004,000자동
2꽃계탕면세24,5009,000×
+
총 합계 ₩ 13,000
+
+
+
+ +

③ 상태별로 할 수 있는 일 (요약표)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
상태의미할 수 있는 일
출고요청주문이 들어갔고 모모 직원이 처리하기 전수량 수정 / 품목 삭제 / 주문 취소 모두 가능. 거래명세표 이미지 공유 / 엑셀.
출고완료물건이 나갔고 거래명세표 메일이 와 있음거래명세표 이미지 공유 / 엑셀 다운로드. 수정 불가.
입금완료입금이 등록됨거래명세표 보기. 세금계산서 대기.
계산서발행전자세금계산서 발행 완료홈택스에서 사업자번호로 조회 가능.
취소주문이 취소됨
+ +

④ 출고요청 상태 — 수량 수정하기

+
    +
  1. 거래명세표 모달 열기[내 주문 내역]에서 출고요청 상태 행 클릭
  2. +
  3. 품목 행의 수량 칸 클릭네모난 입력 칸이 활성화돼요. 새 수량을 입력하세요.
  4. +
  5. 다른 곳 클릭하거나 엔터입력 칸에서 빠져나가는 순간 자동 저장. 위쪽 합계도 즉시 갱신.
  6. +
  7. 재고/한도 초과 시 자동 막힘"재고를 초과할 수 없습니다" 또는 "1회 발주 한도 초과" 알림.
  8. +
+ +

⑤ 출고요청 상태 — 품목 1개만 빼기

+
    +
  1. 품목 행 우측 끝의 [×] 버튼 클릭
  2. +
  3. "이 품목을 주문에서 삭제하시겠습니까?" 확인[삭제] 누르면 그 품목만 빠져요. 다른 품목과 택배/용차는 그대로.
  4. +
+ +

⑥ 출고요청 상태 — 주문 전체 취소

+
    +
  1. 모달 위쪽 [🗑 주문 취소] 버튼 클릭
  2. +
  3. "주문을 취소하시겠습니까?" 확인 → [취소]주문 상태가 취소로 변하고 더는 처리되지 않습니다.
  4. +
+ +
+ ⚠️ 알아두세요 + +
+ +

⑦ 거래명세표 공유 / 엑셀

+

주문 상태별 뜻

@@ -580,17 +807,69 @@ -

다-2. 물건 들어오면 등록하기 (창고에 쌓임)

-

물건이 창고에 도착하면 이 화면에서 등록해요. 등록하면 창고 재고가 늘어나요(+).

+

다-2. 입고 처리 — 발주 선택 후 라인별 입고

+

물건이 창고에 도착하면 이 화면에서 입고 등록 → 재고가 자동으로 증가합니다. 발주 한 번 했지만 일부만 들어오는 경우 — 들어온 만큼만 입력하고 나머지는 다음에 마저 입고할 수 있어요.

+ +

화면 구성 — 좌-우 분할 (발주 선택 → 라인별 입력)

+
+
momotogether.com / 입고 처리
+
+
+
발주서 목록 (입고 가능)
+
+ + + + + + +
발주번호공급업체발주/입고/미입고상태
PRC-20260507-0001(주)AVATEC10 / 0 / 10발주요청
PRC-20260506-0002제일상사20 / 15 / 5입고중
PRC-20260505-0003(주)AVATEC8 / 8 / 0입고완료
+ +
+
입고 입력 — PRC-20260506-0002 💾 입고 등록
+ + + + + + +
#품목발주기입고남은창고정상 입고불량
1꽃계탕 ✓ 완료10100---
2탈취제1055[본사창고 ▾][5][0]
+
+ + + +

입고 처리 단계

    -
  1. 입고할 매입 발주 고르기아직 입고 안 된 발주서 목록이 떠요
  2. -
  3. 입고 개수 / 창고 / 입고일 적기주문할 때 적은 개수랑 실제 들어온 개수가 다를 수 있어요. 부분 입고도 가능해요.
  4. -
  5. [입고 등록] 누르기해당 창고의 재고가 N개만큼 늘어나요. 누가 언제 무엇을 입고했는지 기록도 남아요.
  6. +
  7. 왼쪽에서 발주서 선택'입고 가능 (발주요청 + 입고중)' 필터가 기본. 한 줄 클릭하면 오른쪽에 라인이 펼쳐져요. 입고완료는 더 이상 입력 안 됨.
  8. +
  9. 발주/입고/미입고 숫자 확인예: 10 / 5 / 5 = 발주 10개, 이미 5개 입고됨, 남은 5개. 이 발주는 입고중 상태.
  10. +
  11. 각 라인의 [창고 선택] + [정상 입고] 수량 입력창고는 [창고 관리]에 미리 등록한 곳에서 선택. 정상 입고는 남은 수량 이하로만 입력 가능 (자동 클램프). 불량 있으면 [불량] 칸에도 적기.
  12. +
  13. 입고 안 할 라인은 0으로 두기완전히 입고된 라인(남은 0)은 자동으로 ✓ 완료 표시되고 입력 칸이 사라져요.
  14. +
  15. 위쪽 [💾 입고 등록] 클릭"입고 처리하시겠습니까?" 확인 → [입고]. 누르는 즉시:
    ① 정상 입고 수량만큼 창고 재고 자동 증가
    ② 발주서 라인의 '기입고' 누적
    ③ 모든 라인 완전 입고면 발주서 상태 → 입고완료
    ④ 일부만 입고면 발주서 상태 → 입고중 (다음에 다시 와서 마저 입고)
+ +
+ 💡 부분 입고 시나리오 +

월요일 발주 10개 → 화요일 5개만 도착했다면:

+
    +
  1. 화요일: 발주서 선택 → 정상 입고에 5 입력 → [입고 등록] → 발주서 상태 입고중
  2. +
  3. 목요일 나머지 5개 도착: 같은 발주서를 다시 선택 → 정상 입고에 5 입력 → [입고 등록] → 발주서 상태 입고완료
  4. +
+
+ +

발주/입고/미입고 표시 의미

+ + + + + + + +
표시의미
10 / 0 / 10발주만 했고 입고는 아직. 상태 발주요청
10 / 5 / 5일부만 입고. 상태 입고중 — 5개 더 들어와야 함
10 / 10 / 0전부 입고 완료. 상태 입고완료 — 더 이상 입고 입력 불가
+
💡 입고 vs 출고 — 헷갈리지 마세요
diff --git a/src/app/(main)/m/admin/inbounds/page.tsx b/src/app/(main)/m/admin/inbounds/page.tsx index 87422a4..052ae90 100644 --- a/src/app/(main)/m/admin/inbounds/page.tsx +++ b/src/app/(main)/m/admin/inbounds/page.tsx @@ -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 = { + REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소", +}; +const STATUS_COLOR: Record = { + 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([]); + const [list, setList] = useState([]); + 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([]); + 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>({}); + + const load = useCallback(async () => { + const body: Record = {}; + // 입고 화면은 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 = {}; + 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(); + 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: `매입발주 ${detail.proc.PROC_NO}
총 ${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("
") : "재고가 자동으로 늘어났습니다.", + }); + load(); + loadDetail(); }; - useEffect(() => { load(); }, []); return ( -
-
+
+
-

입고 처리

-

매입발주 후 도매처에서 받은 물품을 창고에 입고. 정상/불량 분리 기록.

+

입고 처리

+

왼쪽에서 발주서를 선택하고, 오른쪽에서 라인별 창고/입고수량을 입력하세요. 부분 입고 가능 — 발주수량까지만.

+
+
+ +
- - 입고 처리 -
-
- - - - - - - - - - - - - - - {list.length === 0 ? ( - - ) : list.map((b) => ( - - - - - - - - - - - ))} - -
입고번호입고일공급업체창고매입발주정상불량합계
입고 이력이 없습니다.
{b.INBOUND_NO}{b.INBOUND_DATE}{b.VENDOR_NAME || "-"}{b.WH_NAME}{b.PROC_NO || "-"}{fmt(b.QTY_NORMAL)}{fmt(b.QTY_DEFECT)}₩{fmt(b.TOTAL_AMOUNT)}
+ +
+ {/* 좌: 매입 발주 리스트 */} +
+
+ 발주서 목록 ({list.length}건) +
+
+ + + + + + + + + + + {list.length === 0 ? ( + + ) : list.map((p) => { + const total = Number(p.TOTAL_QTY); + const recv = Number(p.RECEIVED_QTY); + const remain = Math.max(0, total - recv); + return ( + setActiveId(p.OBJID)} + style={{ cursor: "pointer" }} + className={`border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}> + + + + + + ); + })} + +
발주번호공급업체발주/입고/미입고상태
입고 가능한 발주서가 없습니다.
{p.PROC_NO}
{p.PROC_DATE}
{p.VENDOR_NAME ?? 미선택} + {fmt(total)} + / + {fmt(recv)} + {remain > 0 && (<> + / + {fmt(remain)} + )} + + + {STATUS_LABEL[p.STATUS] ?? p.STATUS} + +
+
+
+ + {/* 우: 입고 입력 폼 */} +
+
+ 입고 처리 입력 + {detail && (detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL") && ( + + )} + {detail && detail.proc.STATUS === "RECEIVED" && ( + + 입고 완료 + + )} +
+
+ {!detail ? ( +
왼쪽에서 매입 발주서를 선택하세요.
+ ) : ( + + )} +
+
); } + +function InboundForm({ detail, warehouses, inputs, onUpdate }: { + detail: { proc: ProcDetail; items: ProcLine[] }; + warehouses: Warehouse[]; + inputs: Record; + onUpdate: (lineObjid: string, patch: Partial<{ whObjid: string; qtyNormal: number; qtyDefect: number }>) => void; +}) { + const editable = detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL"; + return ( +
+
+
+
{detail.proc.PROC_NO}
+
발주일 {detail.proc.PROC_DATE} · 공급업체 {detail.proc.VENDOR_NAME ?? "-"}
+
+ + {detail.proc.STATUS === "PARTIAL" ? : detail.proc.STATUS === "RECEIVED" ? : null} + {STATUS_LABEL[detail.proc.STATUS] ?? detail.proc.STATUS} + +
+ + {!editable && ( +
+ 이 발주서는 {STATUS_LABEL[detail.proc.STATUS]} 상태라 입고 입력이 불가합니다. +
+ )} + + + + + + + + + + + + + + + + {detail.items.length === 0 ? ( + + ) : 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 ( + + + + + + + + + + + ); + })} + +
#품목발주기입고남은창고정상 입고불량
발주 라인이 없습니다.
{idx + 1} + {it.ITEM_NAME} [{it.ITEM_CODE}] + {fullyReceived && ✓ 완료} + {fmt(total)}{fmt(recv)} 0 ? "text-rose-600 font-bold" : "text-slate-400"}`}>{fmt(remain)} + {editable && !fullyReceived ? ( + + ) : -} + + {editable && !fullyReceived ? ( + { + 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" /> + ) : -} + + {editable && !fullyReceived ? ( + { + 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" /> + ) : -} +
+ + {editable && ( +
+ ※ 정상 입고 + 불량은 남은 수량 이하로만 입력 가능합니다. 0으로 두면 그 라인은 입고하지 않습니다.
+ ※ 일부 라인만 입고하면 발주서가 입고중으로 표시되고, 나중에 다시 들어와 마저 입고할 수 있어요. +
+ )} +
+ ); +} diff --git a/src/app/api/m/inbounds/save/route.ts b/src/app/api/m/inbounds/save/route.ts index 5437451..5006864 100644 --- a/src/app/api/m/inbounds/save/route.ts +++ b/src/app/api/m/inbounds/save/route.ts @@ -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]); } } diff --git a/src/app/api/m/procurements/list/route.ts b/src/app/api/m/procurements/list/route.ts index c249693..4dac7a1 100644 --- a/src/app/api/m/procurements/list/route.ts +++ b/src/app/api/m/procurements/list/route.ts @@ -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 ")}