feat(orders): admin 출고관리 — 환불(REFUND) 라인 추가
Deploy momo-erp / deploy (push) Failing after 47s

요청: 출고관리 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 의 차감된 값 그대로 노출 (사용자에게
  최종 청구 금액 표시).
This commit is contained in:
chpark
2026-05-15 02:01:45 +09:00
parent c7d7bdfaea
commit b7c7a4d395
4 changed files with 86 additions and 50 deletions
+55 -32
View File
@@ -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({
>
+
</button>
<button
type="button"
onClick={addRefund}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-rose-100 text-rose-700 text-[11px] font-bold hover:bg-rose-200"
title="환불 라인 추가 — 단가 양수로 입력 시 총액에서 차감됨"
>
+
</button>
</div>
)}
@@ -772,10 +784,10 @@ function StatementPreview({
<tbody className="tabular-nums">
{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 (
<tr className={isDelivery ? "bg-orange-50" : "bg-sky-50"}>
<tr className={bg}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{displaySeq}</td>
<td className="border border-slate-300 px-1.5 py-1">
<span className={`mr-1 text-[9px] font-bold px-1 py-0.5 rounded ${isDelivery ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
{isDelivery ? "택배" : "용차"}
</span>
<span className={`mr-1 text-[9px] font-bold px-1 py-0.5 rounded ${badgeCls}`}>{badgeText}</span>
<input
value={label}
onChange={(e) => 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"
/>
</td>
<td className="border border-slate-300 px-1.5 py-1 text-center text-rose-700"></td>
<td className={`border border-slate-300 px-1.5 py-1 text-center ${isRefund ? "text-violet-700" : "text-rose-700"}`}>
{isRefund ? "면세" : "과세"}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right text-slate-400 js-no-export">-</td>
<td className="border border-slate-300 px-1 py-1 text-right">
<input type="number" min={1} value={qty}
onChange={(e) => 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 ? (
<span className="text-slate-500">1</span>
) : (
<input type="number" min={1} value={qty}
onChange={(e) => 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" />
)}
</td>
<td className="border border-slate-300 px-1 py-1 text-right">
<input type="number" min={0} value={unitPrice}
onChange={(e) => 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" : ""}`} />
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{Number(supply).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{Number(vat).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums font-semibold">{Number(total).toLocaleString("ko-KR")}</td>
<td className={`border border-slate-300 px-1.5 py-1 text-right tabular-nums ${isRefund ? "text-rose-700 font-bold" : ""}`}>{Number(displaySupply).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{isRefund ? "-" : Number(displayVat).toLocaleString("ko-KR")}</td>
<td className={`border border-slate-300 px-1.5 py-1 text-right tabular-nums font-semibold ${isRefund ? "text-rose-700" : ""}`}>{Number(displayTotal).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1 py-0.5">
{editable
? <RemarkInput initial={line.REMARK || ""} onSave={onSaveRemark} />
+1 -1
View File
@@ -409,7 +409,7 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
</tr>
</thead>
<tbody className="tabular-nums">
{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" : "";
+4 -3
View File
@@ -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]
+26 -14
View File
@@ -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++;
}
}