From b7c7a4d395042eb3ae7f7cc93a5b635f3ca1aa91 Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 15 May 2026 02:01:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20admin=20=EC=B6=9C=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20=ED=99=98=EB=B6=88(REFUND)=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청: 출고관리 detail 에 '환불 추가' 버튼. 누르면 표 상단에 환불 라인 생성. 수량=1 고정, 단가 양수로 입력 시 내부에서 음수로 저장 → 총합 자동 차감. - lines/save API: kind 'REFUND' 추가. admin 만 허용. 단가 양수 받아 음수로 저장. is_tax_free='Y' (면세) 처리. seq=0 으로 박아 상단 정렬. - detail/route.ts: ORDER BY 에 REFUND 우선 노출. - admin/orders UI: '환불 추가' 버튼 (rose), ExtraRow 가 REFUND 도 처리 (음수 표시 — 빨간 글씨, 수량 1 고정 잠금). - USER /m/orders: items.filter(KIND !== 'REFUND') — 사용자 화면에서 환불 라인 비공개. 합계는 DB 의 차감된 값 그대로 노출 (사용자에게 최종 청구 금액 표시). --- src/app/(main)/m/admin/orders/page.tsx | 87 +++++++++++++++--------- src/app/(main)/m/orders/page.tsx | 2 +- src/app/api/m/orders/detail/route.ts | 7 +- src/app/api/m/orders/lines/save/route.ts | 40 +++++++---- 4 files changed, 86 insertions(+), 50 deletions(-) diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index fa75b7c..81dfc83 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -22,7 +22,7 @@ interface DetailLine { SEQ: number; ITEM_NAME: string; UNIT: string; UNIT_PRICE: number; QTY: number; IS_TAX_FREE: string; SUPPLY_AMOUNT: number; VAT_AMOUNT: number; TOTAL_AMOUNT: number; STOCK_QTY: number; - KIND: "ITEM" | "DELIVERY" | "CHARTER"; + KIND: "ITEM" | "DELIVERY" | "CHARTER" | "REFUND"; EXTRA_LABEL?: string; REMARK?: string; } @@ -550,7 +550,7 @@ function StatementPreview({ else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message }); }; - const upsertExtra = async (line: { objid?: string; kind: "DELIVERY" | "CHARTER"; label: string; unitPrice: number; qty: number }) => { + const upsertExtra = async (line: { objid?: string; kind: "DELIVERY" | "CHARTER" | "REFUND"; label: string; unitPrice: number; qty: number }) => { const res = await fetch("/api/m/orders/lines/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderObjid: order.OBJID, lines: [line] }), @@ -588,6 +588,10 @@ function StatementPreview({ : { unitPrice: 5000, qty: 1, label: "용차" }; upsertExtra({ kind, ...defaults }); }; + // 환불 라인 추가 — admin 전용. 수량 1 고정, 단가는 양수로 받아 내부 음수 저장. + const addRefund = () => { + upsertExtra({ kind: "REFUND", label: "환불", unitPrice: 0, qty: 1 }); + }; // ITEM 라인 추가 (피커 모달) const [pickerOpen, setPickerOpen] = useState(false); @@ -750,6 +754,14 @@ function StatementPreview({ > + 용차 추가 + )} @@ -772,10 +784,10 @@ function StatementPreview({ {items.map((it, idx) => { const displaySeq = idx + 1; - const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER"; + const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER" || it.KIND === "REFUND"; const lack = !isExtra && Number(it.STOCK_QTY) < Number(it.QTY); - const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null; - const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : ""; + const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : it.KIND === "REFUND" ? "환불" : null; + const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : it.KIND === "REFUND" ? "bg-rose-50" : ""; if (isExtra && editable) { return ( @@ -1005,37 +1017,42 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark } onDelete: () => void; onSaveRemark: (r: string) => void; }) { + const isRefund = line.KIND === "REFUND"; + const isDelivery = line.KIND === "DELIVERY"; + // 환불 라인은 DB에 음수로 저장됨 — UI 에선 양수로 표시/입력 후 저장 시 부호는 API 가 처리. + const initialUnit = isRefund ? Math.abs(Number(line.UNIT_PRICE) || 0) : (Number(line.UNIT_PRICE) || 0); const [label, setLabel] = useState(line.EXTRA_LABEL || line.ITEM_NAME); - const [unitPrice, setUnitPrice] = useState(Number(line.UNIT_PRICE) || 0); - const [qty, setQty] = useState(Number(line.QTY) || 1); + const [unitPrice, setUnitPrice] = useState(initialUnit); + const [qty, setQty] = useState(isRefund ? 1 : (Number(line.QTY) || 1)); - // 외부에서 line 이 갱신되면(예: + 택배 추가 → 서버 재조회) 인풋도 동기화. - // 같은 OBJID 라서 컴포넌트가 unmount 되지 않아 useState 초기값이 무시되는 문제 방지. useEffect(() => { setLabel(line.EXTRA_LABEL || line.ITEM_NAME); - setUnitPrice(Number(line.UNIT_PRICE) || 0); - setQty(Number(line.QTY) || 1); - }, [line.OBJID, line.EXTRA_LABEL, line.ITEM_NAME, line.UNIT_PRICE, line.QTY]); - const total = Math.round(unitPrice * qty); - const supply = Math.round(total / 1.1); - const vat = total - supply; - const isDelivery = line.KIND === "DELIVERY"; + setUnitPrice(isRefund ? Math.abs(Number(line.UNIT_PRICE) || 0) : (Number(line.UNIT_PRICE) || 0)); + setQty(isRefund ? 1 : (Number(line.QTY) || 1)); + }, [line.OBJID, line.EXTRA_LABEL, line.ITEM_NAME, line.UNIT_PRICE, line.QTY, isRefund]); + + // 표시용 금액 — 환불은 음수 + const displayTotal = isRefund ? -unitPrice * qty : Math.round(unitPrice * qty); + const displaySupply = isRefund ? displayTotal : Math.round(displayTotal / 1.1); + const displayVat = isRefund ? 0 : displayTotal - displaySupply; + + const bg = isRefund ? "bg-rose-50" : isDelivery ? "bg-orange-50" : "bg-sky-50"; + const badgeCls = isRefund ? "bg-rose-200 text-rose-800" : isDelivery ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"; + const badgeText = isRefund ? "환불" : isDelivery ? "택배" : "용차"; - // onBlur 시 자동 저장 (값이 바뀐 경우만). V 버튼 제거. const commit = () => { + const originalUnit = isRefund ? Math.abs(Number(line.UNIT_PRICE) || 0) : Number(line.UNIT_PRICE); const dirty = label !== (line.EXTRA_LABEL || line.ITEM_NAME) - || unitPrice !== Number(line.UNIT_PRICE) + || unitPrice !== originalUnit || qty !== Number(line.QTY); if (dirty && qty > 0 && unitPrice >= 0) onSave({ label, unitPrice, qty }); }; return ( - + {displaySeq} - - {isDelivery ? "택배" : "용차"} - + {badgeText} setLabel(e.target.value)} @@ -1044,25 +1061,31 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark } className="h-6 px-1.5 border border-slate-200 rounded text-[11px] bg-white w-[calc(100%-50px)] inline" /> - 과세 + + {isRefund ? "면세" : "과세"} + - - setQty(Number(e.target.value))} - onBlur={commit} - onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" /> + {isRefund ? ( + 1 + ) : ( + setQty(Number(e.target.value))} + onBlur={commit} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" /> + )} setUnitPrice(Number(e.target.value))} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" /> + className={`w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white ${isRefund ? "text-rose-700 font-bold" : ""}`} /> - {Number(supply).toLocaleString("ko-KR")} - {Number(vat).toLocaleString("ko-KR")} - {Number(total).toLocaleString("ko-KR")} + {Number(displaySupply).toLocaleString("ko-KR")} + {isRefund ? "-" : Number(displayVat).toLocaleString("ko-KR")} + {Number(displayTotal).toLocaleString("ko-KR")} {editable ? diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index c3fe866..42c98e2 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -409,7 +409,7 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { - {items.map((it, idx) => { + {items.filter((it) => it.KIND !== "REFUND").map((it, idx) => { const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER"; const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null; const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : ""; diff --git a/src/app/api/m/orders/detail/route.ts b/src/app/api/m/orders/detail/route.ts index 4514d36..6aefe5d 100644 --- a/src/app/api/m/orders/detail/route.ts +++ b/src/app/api/m/orders/detail/route.ts @@ -82,9 +82,10 @@ export async function POST(req: NextRequest) { WHERE OI.order_objid = $1 ORDER BY CASE COALESCE(OI.kind,'ITEM') - WHEN 'DELIVERY' THEN 0 - WHEN 'CHARTER' THEN 1 - ELSE 2 + WHEN 'REFUND' THEN 0 + WHEN 'DELIVERY' THEN 1 + WHEN 'CHARTER' THEN 2 + ELSE 3 END, OI.seq ASC`, [objid] diff --git a/src/app/api/m/orders/lines/save/route.ts b/src/app/api/m/orders/lines/save/route.ts index 411275e..d05f319 100644 --- a/src/app/api/m/orders/lines/save/route.ts +++ b/src/app/api/m/orders/lines/save/route.ts @@ -1,5 +1,5 @@ -// 발주 거래명세표에서 택배(DELIVERY)/용차(CHARTER) 라인 추가/수정/삭제 -// REQUESTED 상태에서만 가능. 본인 또는 관리자. +// 발주 거래명세표에서 택배(DELIVERY)/용차(CHARTER)/환불(REFUND) 라인 추가/수정/삭제 +// REQUESTED/APPROVED 에서 가능. 본인 또는 관리자. (단 REFUND 는 admin 만) // ITEM 라인은 이 엔드포인트로 변경 불가 (별도 흐름 필요). import { NextRequest, NextResponse } from "next/server"; import { pool } from "@/lib/db"; @@ -9,9 +9,9 @@ import { calcLine } from "@/lib/momo-pricing"; interface ExtraLineInput { objid?: string; // 기존 라인 수정/삭제 시 - kind: "DELIVERY" | "CHARTER"; + kind: "DELIVERY" | "CHARTER" | "REFUND"; label?: string; - unitPrice: number; + unitPrice: number; // REFUND 는 양수로 받아 내부에서 음수 저장 qty: number; delete?: boolean; } @@ -60,29 +60,39 @@ export async function POST(req: NextRequest) { let nextSeq = Number(seqRes.rows[0]?.m ?? 0) + 1; for (const ln of lines) { - if (ln.kind !== "DELIVERY" && ln.kind !== "CHARTER") { + if (ln.kind !== "DELIVERY" && ln.kind !== "CHARTER" && ln.kind !== "REFUND") { await client.query("ROLLBACK"); return NextResponse.json({ success: false, message: "라인 종류가 올바르지 않습니다." }, { status: 400 }); } + // REFUND 는 admin 만 + if (ln.kind === "REFUND" && !isAdmin) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "환불 라인은 관리자만 추가 가능합니다." }, { status: 403 }); + } if (ln.delete && ln.objid) { // 삭제 — ITEM 라인은 보호 await client.query( `DELETE FROM momo_order_items - WHERE objid = $1 AND order_objid = $2 AND kind IN ('DELIVERY','CHARTER')`, + WHERE objid = $1 AND order_objid = $2 AND kind IN ('DELIVERY','CHARTER','REFUND')`, [ln.objid, orderObjid] ); continue; } - const unitPrice = Math.round(Number(ln.unitPrice) || 0); + const isRefund = ln.kind === "REFUND"; + const rawUnit = Math.round(Number(ln.unitPrice) || 0); const qty = Number(ln.qty) || 0; - if (unitPrice < 0 || qty <= 0) { + if (rawUnit < 0 || qty <= 0) { await client.query("ROLLBACK"); return NextResponse.json({ success: false, message: "단가/수량 형식이 올바르지 않습니다." }, { status: 400 }); } - const calc = calcLine({ unitPrice, qty, isTaxFree: false }); - const label = ln.label?.trim() || (ln.kind === "DELIVERY" ? "택배비" : "용차비"); + // 환불: 양수로 받아 음수로 저장 (총합 자동 차감). 면세 처리 — 세액 0. + const unitPrice = isRefund ? -rawUnit : rawUnit; + const calc = isRefund + ? { supplyAmount: -rawUnit * qty, vatAmount: 0, totalAmount: -rawUnit * qty } + : calcLine({ unitPrice, qty, isTaxFree: false }); + const label = ln.label?.trim() || (ln.kind === "DELIVERY" ? "택배비" : ln.kind === "CHARTER" ? "용차비" : "환불"); if (ln.objid) { // 수정 @@ -101,16 +111,18 @@ export async function POST(req: NextRequest) { calc.supplyAmount, calc.vatAmount, calc.totalAmount, ln.kind] ); } else { - // 신규 + // 신규 — REFUND 는 seq=0 으로 박아 표 상단에 표시 + const seqForInsert = isRefund ? 0 : nextSeq; await client.query( `INSERT INTO momo_order_items ( objid, order_objid, item_objid, item_name_snap, unit_price, qty, is_tax_free, supply_amount, vat_amount, total_amount, seq, kind, extra_label - ) VALUES ($1, $2, NULL, $3, $4, $5, 'N', $6, $7, $8, $9, $10, $3)`, + ) VALUES ($1, $2, NULL, $3, $4, $5, $11, $6, $7, $8, $9, $10, $3)`, [createObjectId(), orderObjid, label, unitPrice, qty, - calc.supplyAmount, calc.vatAmount, calc.totalAmount, nextSeq, ln.kind] + calc.supplyAmount, calc.vatAmount, calc.totalAmount, seqForInsert, ln.kind, + isRefund ? "Y" : "N"] ); - nextSeq++; + if (!isRefund) nextSeq++; } }