feat(momo v0.4): 발주서 택배/용차 라인 + 택배전용 품목 자동 라인 + 모바일 반응형
Deploy momo-erp / deploy (push) Successful in 56s
Deploy momo-erp / deploy (push) Successful in 56s
[DB 010] - momo_items.requires_delivery (택배 전용 플래그) - momo_order_items.kind (ITEM/DELIVERY/CHARTER) + extra_label - momo_orders.total_delivery / total_charter [발주] - /api/m/orders/save: 택배/용차 라인 처리, 택배전용 품목이 있으면 택배 라인 필수 검증 - /api/m/orders/detail: kind/extra_label/택배비/용차비 응답 - /m/orders/new 재설계: · 택배/용차 추가 버튼 (한 줄씩 생성, 담당자명+금액 수기 입력) · 택배전용 품목 카트에 담기면 자동으로 택배 라인 1줄 추가, 제거 시 차단 · 카트 수량 직접 입력 가능 (재고/한도 자동 클램프) · 모바일 반응형 (2열 그리드, 터치 친화 패딩, sticky 카트바 압축) [품목] - 관리자 등록/수정 폼: 택배 전용 라디오 추가 - 그리드 배지에 택배 표시 - /api/m/items/list: REQUIRES_DELIVERY 응답 - /api/m/items/save: requiresDelivery 필드 처리 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- 010_delivery_charter.sql
|
||||
-- v0.4 (2026-04-27)
|
||||
-- 발주서에 택배비/용차비 라인 + 택배 전용 품목 자동 라인 지원
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. momo_items: 택배 전용 플래그
|
||||
ALTER TABLE momo_items
|
||||
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) NOT NULL DEFAULT 'N';
|
||||
COMMENT ON COLUMN momo_items.requires_delivery
|
||||
IS '택배 전용 품목 (Y) — 카트에 담기면 택배 라인이 자동으로 추가됨';
|
||||
|
||||
-- 2. momo_order_items: 라인 종류 + 라벨
|
||||
-- kind: 'ITEM'(품목) / 'DELIVERY'(택배비) / 'CHARTER'(용차비)
|
||||
ALTER TABLE momo_order_items
|
||||
ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'ITEM',
|
||||
ADD COLUMN IF NOT EXISTS extra_label VARCHAR(100);
|
||||
COMMENT ON COLUMN momo_order_items.kind
|
||||
IS 'ITEM=품목 / DELIVERY=택배비 / CHARTER=용차비';
|
||||
COMMENT ON COLUMN momo_order_items.extra_label
|
||||
IS '택배비/용차비 라인의 담당자명 또는 부가 메모';
|
||||
|
||||
-- 기존 가맹 데이터는 ITEM 으로 간주
|
||||
UPDATE momo_order_items SET kind = 'ITEM' WHERE kind IS NULL;
|
||||
|
||||
-- 3. momo_orders: 택배비/용차비 합계 (집계 편의용)
|
||||
ALTER TABLE momo_orders
|
||||
ADD COLUMN IF NOT EXISTS total_delivery NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_charter NUMERIC(15,2) DEFAULT 0;
|
||||
COMMENT ON COLUMN momo_orders.total_delivery IS '택배비 라인 합계';
|
||||
COMMENT ON COLUMN momo_orders.total_charter IS '용차비 라인 합계';
|
||||
|
||||
COMMIT;
|
||||
@@ -21,6 +21,7 @@ interface Item {
|
||||
ATTRIBUTES: Record<string, unknown> | null;
|
||||
MAX_ORDER_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
}
|
||||
|
||||
interface Maker { OBJID: string; MAKER_NAME: string }
|
||||
@@ -80,7 +81,7 @@ export default function AdminItemsPage() {
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null });
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
|
||||
setAttrs({});
|
||||
};
|
||||
|
||||
@@ -103,6 +104,7 @@ export default function AdminItemsPage() {
|
||||
attributes: Object.keys(attrs).length > 0 ? attrs : null,
|
||||
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
|
||||
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
|
||||
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
|
||||
};
|
||||
const res = await fetch("/api/m/items/save", {
|
||||
method: "POST",
|
||||
@@ -260,6 +262,9 @@ export default function AdminItemsPage() {
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">≤{it.MAX_ORDER_QTY}</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
@@ -405,6 +410,26 @@ export default function AdminItemsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="택배 전용 (담기면 자동 택배 라인 추가)">
|
||||
<div className="flex gap-2 h-10">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY !== "Y"}
|
||||
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "N" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
일반
|
||||
</label>
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY === "Y"}
|
||||
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "Y" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
택배전용
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상세 설명">
|
||||
<textarea
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, ShoppingCart, Plus, Minus, X } from "lucide-react";
|
||||
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Item {
|
||||
@@ -16,10 +16,15 @@ interface Item {
|
||||
IS_TAX_FREE: string;
|
||||
IMAGE_URL: string;
|
||||
STOCK_QTY: number;
|
||||
MAX_ORDER_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
}
|
||||
interface CartLine { item: Item; qty: number }
|
||||
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; amount: number; label: string }
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
|
||||
export default function ItemsBrowse() {
|
||||
const router = useRouter();
|
||||
@@ -28,8 +33,10 @@ export default function ItemsBrowse() {
|
||||
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [extras, setExtras] = useState<ExtraLine[]>([]);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
|
||||
const fetchItems = async () => {
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/m/items/list", {
|
||||
method: "POST",
|
||||
@@ -39,30 +46,50 @@ export default function ItemsBrowse() {
|
||||
const j = await res.json();
|
||||
setItems(j.RESULTLIST ?? []);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [keyword, taxFilter]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
|
||||
|
||||
// 카트에 택배전용 품목이 있는지
|
||||
const cartNeedsDelivery = useMemo(
|
||||
() => cart.some((c) => c.item.REQUIRES_DELIVERY === "Y"),
|
||||
[cart]
|
||||
);
|
||||
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
|
||||
|
||||
// 택배전용 품목이 카트에 들어왔는데 택배 라인이 없으면 자동 한 줄 추가
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (cartNeedsDelivery && !hasDeliveryLine) {
|
||||
setExtras((prev) => [
|
||||
{ id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
|
||||
...prev,
|
||||
]);
|
||||
}
|
||||
}, [cartNeedsDelivery, hasDeliveryLine]);
|
||||
|
||||
const addToCart = (item: Item) => {
|
||||
setCart((c) => {
|
||||
const found = c.find((x) => x.item.OBJID === item.OBJID);
|
||||
const stock = Number(item.STOCK_QTY);
|
||||
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
if (found) {
|
||||
if (found.qty + 1 > Number(item.STOCK_QTY)) {
|
||||
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고는 ${fmt(item.STOCK_QTY)}개입니다.` });
|
||||
if (found.qty + 1 > limit) {
|
||||
Swal.fire({
|
||||
icon: "warning",
|
||||
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
|
||||
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}개` : `재고는 ${fmt(stock)}개`}입니다.`,
|
||||
});
|
||||
return c;
|
||||
}
|
||||
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
|
||||
}
|
||||
return [...c, { item, qty: 1 }];
|
||||
});
|
||||
// 시각 피드백: 우측 상단 토스트
|
||||
Swal.fire({
|
||||
toast: true, position: "top-end", icon: "success",
|
||||
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
|
||||
showConfirmButton: false, timer: 1500, timerProgressBar: true,
|
||||
showConfirmButton: false, timer: 1200, timerProgressBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,16 +99,47 @@ export default function ItemsBrowse() {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
const newQty = x.qty + delta;
|
||||
if (newQty <= 0) return x;
|
||||
if (newQty > Number(x.item.STOCK_QTY)) return x;
|
||||
const stock = Number(x.item.STOCK_QTY);
|
||||
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
if (newQty > limit) return x;
|
||||
return { ...x, qty: newQty };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const setQty = (objid: string, value: number) => {
|
||||
setCart((c) =>
|
||||
c.map((x) => {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
const stock = Number(x.item.STOCK_QTY);
|
||||
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
|
||||
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
|
||||
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
|
||||
return { ...x, qty: clamped };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
|
||||
|
||||
const addExtra = (kind: "DELIVERY" | "CHARTER") => {
|
||||
setExtras((p) => [...p, { id: newKey(), kind, amount: 0, label: kind === "DELIVERY" ? "택배비" : "용차비" }]);
|
||||
};
|
||||
const updateExtra = (id: string, field: keyof ExtraLine, value: string | number) => {
|
||||
setExtras((p) => p.map((e) => (e.id === id ? { ...e, [field]: value } as ExtraLine : e)));
|
||||
};
|
||||
const removeExtra = (id: string) => {
|
||||
const target = extras.find((e) => e.id === id);
|
||||
if (target?.kind === "DELIVERY" && cartNeedsDelivery) {
|
||||
Swal.fire({ icon: "warning", title: "택배 전용 품목이 있어 택배 라인을 제거할 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
setExtras((p) => p.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0;
|
||||
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, delivery = 0, charter = 0;
|
||||
for (const ln of cart) {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
total += lineTotal;
|
||||
@@ -95,18 +153,36 @@ export default function ItemsBrowse() {
|
||||
taxable += s;
|
||||
}
|
||||
}
|
||||
return { supply, vat, total, taxFree, taxable };
|
||||
}, [cart]);
|
||||
for (const ex of extras) {
|
||||
const amt = Number(ex.amount) || 0;
|
||||
total += amt;
|
||||
const s = Math.round(amt / 1.1);
|
||||
supply += s;
|
||||
vat += amt - s;
|
||||
taxable += s;
|
||||
if (ex.kind === "DELIVERY") delivery += amt;
|
||||
if (ex.kind === "CHARTER") charter += amt;
|
||||
}
|
||||
return { supply, vat, total, taxFree, taxable, delivery, charter };
|
||||
}, [cart, extras]);
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (cart.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
|
||||
return;
|
||||
}
|
||||
if (cartNeedsDelivery && !hasDeliveryLine) {
|
||||
Swal.fire({ icon: "warning", title: "택배 전용 품목이 포함되어 택배 라인이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
if (extras.some((e) => Number(e.amount) <= 0)) {
|
||||
Swal.fire({ icon: "warning", title: "택배/용차 금액을 입력하세요." });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({
|
||||
icon: "question",
|
||||
title: "발주를 요청하시겠습니까?",
|
||||
text: `합계 ₩${fmt(totals.total)} (${cart.length}개 품목)`,
|
||||
text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.length})`,
|
||||
showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
@@ -116,43 +192,42 @@ export default function ItemsBrowse() {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
|
||||
extras: extras.map((e) => ({ kind: e.kind, amount: Number(e.amount), label: e.label })),
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
|
||||
setCart([]);
|
||||
setCart([]); setExtras([]);
|
||||
router.push("/m/orders");
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
}
|
||||
};
|
||||
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ===== 상단 sticky 카트 바 — 항상 노출, 클릭하면 내역 펼침 ===== */}
|
||||
{/* ===== 상단 sticky 카트 바 ===== */}
|
||||
<div className="sticky top-0 z-20 bg-white border-2 border-emerald-300 rounded-xl shadow-lg mb-3 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setCartOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-emerald-50/40 transition"
|
||||
className="w-full flex items-center justify-between gap-2 px-3 sm:px-4 py-2.5 hover:bg-emerald-50/40 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800">
|
||||
<ShoppingCart size={18} className="text-emerald-700" />
|
||||
발주 장바구니
|
||||
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-bold tabular-nums">
|
||||
{cart.length}
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-sm sm:text-base min-w-0">
|
||||
<ShoppingCart size={16} className="text-emerald-700 shrink-0" />
|
||||
<span className="truncate">발주 장바구니</span>
|
||||
<span className="shrink-0 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[11px] font-bold tabular-nums">
|
||||
{cart.length + extras.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden md:inline text-xs text-violet-700 tabular-nums">면세 ₩{fmt(totals.taxFree)}</span>
|
||||
<span className="hidden md:inline text-xs text-rose-700 tabular-nums">과세 ₩{fmt(totals.taxable)}</span>
|
||||
<span className="text-base font-bold text-emerald-700 tabular-nums">₩{fmt(totals.total)}</span>
|
||||
<span className="text-sm sm:text-base font-bold text-emerald-700 tabular-nums">₩{fmt(totals.total)}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); submitOrder(); }}
|
||||
disabled={cart.length === 0}
|
||||
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
className="h-8 sm:h-9 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
발주 요청
|
||||
</button>
|
||||
@@ -161,61 +236,134 @@ export default function ItemsBrowse() {
|
||||
</button>
|
||||
|
||||
{cartOpen && (
|
||||
<div className="border-t border-emerald-100 px-4 py-3 max-h-[40vh] overflow-y-auto bg-slate-50/50">
|
||||
{cart.length === 0 ? (
|
||||
<div className="border-t border-emerald-100 px-3 sm:px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
|
||||
{/* 택배/용차 추가 버튼 */}
|
||||
<div className="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addExtra("DELIVERY")}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-orange-100 text-orange-700 text-xs font-bold hover:bg-orange-200"
|
||||
>
|
||||
<Truck size={13} /> + 택배 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addExtra("CHARTER")}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-sky-100 text-sky-700 text-xs font-bold hover:bg-sky-200"
|
||||
>
|
||||
<Package size={13} /> + 용차 추가
|
||||
</button>
|
||||
</div>
|
||||
{(cart.length > 0 || extras.length > 0) && (
|
||||
<button
|
||||
onClick={() => { setCart([]); setExtras([]); }}
|
||||
className="text-xs text-slate-400 hover:text-rose-500"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 택배/용차 라인 */}
|
||||
{extras.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{extras.map((ex) => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
|
||||
>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
|
||||
{ex.kind === "DELIVERY" ? "택배" : "용차"}
|
||||
</span>
|
||||
<input
|
||||
value={ex.label}
|
||||
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
|
||||
placeholder="담당자/메모"
|
||||
className="flex-1 min-w-0 h-8 px-2 rounded border border-slate-200 text-xs bg-white"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={ex.amount || ""}
|
||||
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
|
||||
placeholder="금액"
|
||||
className="w-24 sm:w-32 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeExtra(ex.id)}
|
||||
className="text-slate-300 hover:text-rose-500 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 라인 */}
|
||||
{cart.length === 0 && extras.length === 0 ? (
|
||||
<div className="text-slate-400 text-sm text-center py-6">
|
||||
아래 품목 카드의 <span className="font-bold text-emerald-700">+ 담기</span> 버튼으로 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
|
||||
전체 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{cart.map((ln) => {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
return (
|
||||
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
|
||||
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
|
||||
<X size={14} />
|
||||
) : cart.length > 0 && (
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{cart.map((ln) => {
|
||||
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||
return (
|
||||
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="text-sm font-semibold leading-tight min-w-0">
|
||||
<div className="truncate">{ln.item.ITEM_NAME}</div>
|
||||
{ln.item.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="inline-block mt-0.5 text-[9px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배전용</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500 shrink-0">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={ln.qty}
|
||||
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
|
||||
className="w-12 h-7 text-center text-sm font-bold tabular-nums border border-slate-200 rounded"
|
||||
/>
|
||||
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
|
||||
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm font-bold tabular-nums">₩{fmt(lineTotal)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-bold tabular-nums">₩{fmt(lineTotal)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 mt-3 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<Row label="면세 합계" value={`₩${fmt(totals.taxFree)}`} color="violet" />
|
||||
<Row label="과세 공급가" value={`₩${fmt(totals.taxable)}`} color="rose" />
|
||||
<Row label="세액" value={`₩${fmt(totals.vat)}`} />
|
||||
<Row label="총 합계" value={`₩${fmt(totals.total)}`} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-2 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<Row label="면세 합계" value={`₩${fmt(totals.taxFree)}`} color="violet" />
|
||||
<Row label="과세 공급가" value={`₩${fmt(totals.taxable)}`} color="rose" />
|
||||
<Row label="세액" value={`₩${fmt(totals.vat)}`} />
|
||||
<Row label="총 합계" value={`₩${fmt(totals.total)}`} />
|
||||
{totals.delivery > 0 && <Row label="택배비" value={`₩${fmt(totals.delivery)}`} color="orange" />}
|
||||
{totals.charter > 0 && <Row label="용차비" value={`₩${fmt(totals.charter)}`} color="sky" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">출고 요청</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.</p>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">출고 요청</h1>
|
||||
<p className="text-slate-500 text-xs sm:text-sm mt-1">현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -229,12 +377,12 @@ export default function ItemsBrowse() {
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/15 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-2 sm:px-3 rounded-lg border border-slate-200 text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="Y">면세</option>
|
||||
<option value="N">과세</option>
|
||||
</select>
|
||||
<button onClick={fetchItems} className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
|
||||
<button onClick={fetchItems} className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
@@ -244,10 +392,10 @@ export default function ItemsBrowse() {
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100">검색 결과가 없습니다.</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 gap-2 sm:gap-3">
|
||||
{items.map((it) => (
|
||||
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
|
||||
<div className="aspect-square bg-slate-50 rounded-lg mb-3 overflow-hidden flex items-center justify-center">
|
||||
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition">
|
||||
<div className="aspect-square bg-slate-50 rounded-lg mb-2 sm:mb-3 overflow-hidden flex items-center justify-center">
|
||||
{it.IMAGE_URL ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
|
||||
@@ -255,25 +403,33 @@ export default function ItemsBrowse() {
|
||||
<div className="text-slate-300 text-xs">이미지 없음</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-bold text-slate-900 tabular-nums">₩{fmt(it.UNIT_PRICE)}</div>
|
||||
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
재고 {fmt(it.STOCK_QTY)} {it.UNIT}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<div className="font-bold text-xs sm:text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||
<div className="flex flex-col gap-0.5 items-end shrink-0">
|
||||
{it.IS_TAX_FREE === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold">면세</span>
|
||||
)}
|
||||
{it.REQUIRES_DELIVERY === "Y" && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold">택배</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mb-1.5 truncate">{it.MAKER_NAME || "-"}</div>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<div className="font-bold text-slate-900 tabular-nums text-sm">₩{fmt(it.UNIT_PRICE)}</div>
|
||||
<div className={`text-[11px] font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||
재고 {fmt(it.STOCK_QTY)}{it.UNIT}
|
||||
</div>
|
||||
</div>
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<div className="text-[10px] text-sky-700 mb-1">1회 한도 ≤ {fmt(it.MAX_ORDER_QTY)}</div>
|
||||
)}
|
||||
<button
|
||||
disabled={Number(it.STOCK_QTY) === 0}
|
||||
onClick={() => addToCart(it)}
|
||||
className="w-full mt-3 h-9 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
className="w-full mt-1.5 h-8 sm:h-9 rounded-lg bg-emerald-700 text-white text-[11px] sm:text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
<Plus size={14} /> 담기
|
||||
<Plus size={13} /> 담기
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -284,8 +440,12 @@ export default function ItemsBrowse() {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" }) {
|
||||
const cls = color === "violet" ? "text-violet-700" : color === "rose" ? "text-rose-700" : "text-slate-700";
|
||||
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" | "orange" | "sky" }) {
|
||||
const cls = color === "violet" ? "text-violet-700"
|
||||
: color === "rose" ? "text-rose-700"
|
||||
: color === "orange" ? "text-orange-700"
|
||||
: color === "sky" ? "text-sky-700"
|
||||
: "text-slate-700";
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className={cls}>{label}</span>
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function POST(req: NextRequest) {
|
||||
I.attributes AS "ATTRIBUTES",
|
||||
I.max_order_qty AS "MAX_ORDER_QTY",
|
||||
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
|
||||
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
|
||||
@@ -24,9 +24,11 @@ export async function POST(req: NextRequest) {
|
||||
status,
|
||||
maxOrderQty,
|
||||
isHidden,
|
||||
requiresDelivery,
|
||||
} = body;
|
||||
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
|
||||
const hidden = isHidden === "Y" ? "Y" : "N";
|
||||
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
|
||||
|
||||
if (!itemName) {
|
||||
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
|
||||
@@ -45,15 +47,15 @@ export async function POST(req: NextRequest) {
|
||||
`INSERT INTO momo_items (
|
||||
objid, item_code, item_name, item_detail, maker_objid,
|
||||
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
|
||||
max_order_qty, is_hidden,
|
||||
max_order_qty, is_hidden, requires_delivery,
|
||||
is_del, regdate, regid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,'N',NOW(),$15)`,
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,'N',NOW(),$16)`,
|
||||
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
|
||||
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
maxQty, hidden, reqDelivery,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: newId, itemCode });
|
||||
@@ -65,15 +67,15 @@ export async function POST(req: NextRequest) {
|
||||
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
|
||||
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
|
||||
attributes=$10::jsonb, status=$11,
|
||||
max_order_qty=$12, is_hidden=$13,
|
||||
update_date=NOW(), update_id=$14
|
||||
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
|
||||
update_date=NOW(), update_id=$15
|
||||
WHERE objid=$1`,
|
||||
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
|
||||
Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
maxQty, hidden, reqDelivery,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: objid });
|
||||
|
||||
@@ -21,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
|
||||
O.total_amount AS "TOTAL_AMOUNT",
|
||||
O.total_taxfree AS "TOTAL_TAXFREE", O.total_taxable AS "TOTAL_TAXABLE",
|
||||
COALESCE(O.total_delivery, 0) AS "TOTAL_DELIVERY",
|
||||
COALESCE(O.total_charter, 0) AS "TOTAL_CHARTER",
|
||||
O.invoice_no AS "INVOICE_NO",
|
||||
TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE",
|
||||
O.paid_amount AS "PAID_AMOUNT",
|
||||
@@ -47,6 +49,8 @@ export async function POST(req: NextRequest) {
|
||||
OI.supply_amount AS "SUPPLY_AMOUNT",
|
||||
OI.vat_amount AS "VAT_AMOUNT",
|
||||
OI.total_amount AS "TOTAL_AMOUNT",
|
||||
COALESCE(OI.kind, 'ITEM') AS "KIND",
|
||||
OI.extra_label AS "EXTRA_LABEL",
|
||||
I.unit AS "UNIT",
|
||||
I.image_url AS "IMAGE_URL",
|
||||
COALESCE(
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
// 출고요청서 작성 (대리점) — status=REQUESTED 로 저장
|
||||
// v0.4: 택배비/용차비 라인 + 택배 전용 품목 자동 검증 지원
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
import { calcLine, sumTotals } from "@/lib/momo-pricing";
|
||||
|
||||
interface InputLine {
|
||||
interface InputItemLine {
|
||||
itemObjid: string;
|
||||
qty: number;
|
||||
}
|
||||
interface InputExtraLine {
|
||||
kind: "DELIVERY" | "CHARTER";
|
||||
amount: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
// MOMO 가입자는 r.user.objid 가 user_id 와 동일하게 채워지지만,
|
||||
// FITO 사용자(예: plm_admin)는 objid 가 undefined 이므로 userId 로 폴백.
|
||||
const customerObjid = r.user.objid || r.user.userId;
|
||||
if (!customerObjid) {
|
||||
return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
let lines: InputLine[];
|
||||
let lines: InputItemLine[];
|
||||
let extras: InputExtraLine[];
|
||||
let memo: string | undefined;
|
||||
try {
|
||||
const body = await req.json() as { lines: InputLine[]; memo?: string };
|
||||
const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string };
|
||||
lines = body.lines;
|
||||
extras = Array.isArray(body.extras) ? body.extras : [];
|
||||
memo = body.memo;
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 });
|
||||
@@ -37,9 +43,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: "품목/수량 형식이 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
for (const ex of extras) {
|
||||
if (ex.kind !== "DELIVERY" && ex.kind !== "CHARTER") {
|
||||
return NextResponse.json({ success: false, message: "택배/용차 라인 종류가 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
if (!Number.isFinite(Number(ex.amount)) || Number(ex.amount) < 0) {
|
||||
return NextResponse.json({ success: false, message: "택배/용차 금액 형식이 올바르지 않습니다." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 발주자(회원) 권한 — unlimited_qty='Y' 면 max_order_qty 무시
|
||||
const customerRow = await pool.query(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`,
|
||||
[customerObjid]
|
||||
@@ -51,7 +64,9 @@ export async function POST(req: NextRequest) {
|
||||
const items = await pool.query(
|
||||
`SELECT
|
||||
I.objid, I.item_name, I.unit_price, I.is_tax_free,
|
||||
I.max_order_qty, COALESCE(I.is_hidden, 'N') AS is_hidden,
|
||||
I.max_order_qty,
|
||||
COALESCE(I.is_hidden, 'N') AS is_hidden,
|
||||
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
@@ -68,7 +83,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 수량 검증: 재고 한도 + (unlimited_qty 가 아니면) max_order_qty 한도
|
||||
// 수량/숨김/택배 검증
|
||||
let needsDelivery = false;
|
||||
for (const ln of lines) {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const stock = Number(it.stock_qty ?? 0);
|
||||
@@ -87,10 +103,22 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (it.requires_delivery === "Y") needsDelivery = true;
|
||||
}
|
||||
|
||||
// 택배 전용 품목이 있는데 택배 라인이 없으면 차단
|
||||
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
|
||||
if (needsDelivery && !hasDeliveryLine) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: "택배 전용 품목이 포함되어 택배 라인이 필요합니다. 택배 추가 후 다시 시도하세요.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const orderObjid = createObjectId();
|
||||
const orderNo = await genOrderNo();
|
||||
|
||||
// 품목 라인
|
||||
const enriched = lines.map((ln, idx) => {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const isFree = it.is_tax_free === "Y";
|
||||
@@ -105,7 +133,30 @@ export async function POST(req: NextRequest) {
|
||||
...calc,
|
||||
};
|
||||
});
|
||||
const totals = sumTotals(enriched);
|
||||
|
||||
// 택배/용차 라인 — 입력 금액이 VAT 포함 합계라고 가정, 일반 과세 처리
|
||||
let totalDelivery = 0;
|
||||
let totalCharter = 0;
|
||||
const extraEnriched = extras.map((ex, idx) => {
|
||||
const amount = Math.round(Number(ex.amount));
|
||||
const calc = calcLine({ unitPrice: amount, qty: 1, isTaxFree: false });
|
||||
if (ex.kind === "DELIVERY") totalDelivery += amount;
|
||||
if (ex.kind === "CHARTER") totalCharter += amount;
|
||||
return {
|
||||
seq: enriched.length + idx + 1,
|
||||
kind: ex.kind,
|
||||
label: ex.label?.trim() || (ex.kind === "DELIVERY" ? "택배비" : "용차비"),
|
||||
unitPrice: amount,
|
||||
qty: 1,
|
||||
isTaxFree: false,
|
||||
...calc,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = sumTotals([
|
||||
...enriched,
|
||||
...extraEnriched,
|
||||
]);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -114,22 +165,35 @@ export async function POST(req: NextRequest) {
|
||||
`INSERT INTO momo_orders (
|
||||
objid, order_no, customer_objid, order_date, status,
|
||||
total_supply, total_vat, total_amount, total_taxfree, total_taxable,
|
||||
total_delivery, total_charter,
|
||||
memo, regdate, regid
|
||||
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,NOW(),$10)`,
|
||||
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12)`,
|
||||
[orderObjid, orderNo, customerObjid,
|
||||
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable, memo ?? null,
|
||||
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable,
|
||||
totalDelivery, totalCharter,
|
||||
memo ?? null,
|
||||
customerObjid]
|
||||
);
|
||||
for (const ln of enriched) {
|
||||
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
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
is_tax_free, supply_amount, vat_amount, total_amount, seq, kind
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'ITEM')`,
|
||||
[createObjectId(), orderObjid, ln.itemObjid, ln.itemName, ln.unitPrice, ln.qty,
|
||||
ln.isTaxFree ? "Y" : "N", ln.supplyAmount, ln.vatAmount, ln.totalAmount, ln.seq]
|
||||
);
|
||||
}
|
||||
for (const ex of extraEnriched) {
|
||||
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,1,'N',$5,$6,$7,$8,$9,$10)`,
|
||||
[createObjectId(), orderObjid, ex.label, ex.unitPrice,
|
||||
ex.supplyAmount, ex.vatAmount, ex.totalAmount, ex.seq, ex.kind, ex.label]
|
||||
);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true, objId: orderObjid, orderNo });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user