From 03838af90c6b893858932807b2044191c0c89bce Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 8 May 2026 00:38:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B1=B0=EB=9E=98=EC=B2=98=20=EC=B6=9C?= =?UTF-8?q?=EA=B3=A0=EC=9D=B4=EB=A0=A5):=20=EB=B3=B8=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=B6=9C=EA=B3=A0=EC=9A=94=EC=B2=AD=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=EC=88=98=EB=9F=89/=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [새 API: /api/m/orders/items/update] - 본인 또는 관리자가 자기 발주의 품목(ITEM) 라인 수량 변경 또는 삭제 - REQUESTED 상태에서만 허용. 단가는 변경 불가 (momo_items.unit_price 기준 자동 재계산) - 재고 / max_order_qty 한도 자동 검증 (unlimited_qty 권한이면 한도 우회) - 트랜잭션으로 라인 수정 + momo_orders 합계 7종 자동 재집계 [/m/orders 거래명세표 모달 UI] - 출고요청 상태 거래처 본인 화면에서 품목 라인 직접 편집: · 수량 인풋 (블러 시 자동 저장) · 행 끝의 [×] 버튼으로 그 품목만 삭제 - 택배/용차 라인은 인풋 안 보이고 "자동" 표시 — 모모 담당자가 조정 - 저장/삭제 후 onReload 로 모달 + 리스트 동시 갱신 - 안내 배너: "수량 수정 / 품목 삭제 / 주문 취소" 모두 가능 명시 [매뉴얼] - 가-2 내 발주 이력 섹션에 수량 수정 / 품목 삭제 / 주문 취소 사용법 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- public/manual.html | 9 +- src/app/(main)/m/orders/page.tsx | 75 ++++++++++- src/app/api/m/orders/items/update/route.ts | 150 +++++++++++++++++++++ 3 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 src/app/api/m/orders/items/update/route.ts diff --git a/public/manual.html b/public/manual.html index 356f11a..c7372de 100644 --- a/public/manual.html +++ b/public/manual.html @@ -382,7 +382,14 @@

주문 상태별 뜻

diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index 012a622..c752a63 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -176,22 +176,53 @@ export default function MyOrdersPage() { supplier={detail.supplier} onClose={() => setDetail(null)} onCancel={() => cancelOrder(detail.order)} + onReload={async () => { + const res = await fetch("/api/m/orders/detail", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: detail.order.OBJID }), + }); + const j = await res.json(); + if (j.success) setDetail({ order: j.order, items: j.items, supplier: j.supplier }); + load(); + }} /> )} ); } -function DetailModal({ order, items, supplier, onClose, onCancel }: { +function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { order: Order & { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; EMAIL?: string }; items: DetailLine[]; supplier: Supplier; onClose: () => void; onCancel: () => void; + onReload: () => void; }) { const ref = useRef(null); const editable = order.STATUS === "REQUESTED"; + const updateItemLine = async (lineObjid: string, qty: number) => { + const res = await fetch("/api/m/orders/items/update", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, qty }] }), + }); + const j = await res.json(); + if (j.success) onReload(); + else Swal.fire({ icon: "error", title: "수정 실패", text: j.message }); + }; + const deleteItemLine = async (lineObjid: string) => { + const ok = await Swal.fire({ icon: "question", title: "이 품목을 주문에서 삭제하시겠습니까?", showCancelButton: true, confirmButtonText: "삭제", cancelButtonText: "취소", confirmButtonColor: "#dc2626" }); + if (!ok.isConfirmed) return; + const res = await fetch("/api/m/orders/items/update", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, delete: true }] }), + }); + const j = await res.json(); + if (j.success) onReload(); + else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message }); + }; + const captureAndShare = async () => { try { const mod = await import("html-to-image"); @@ -278,18 +309,24 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: { -
+ {editable && ( +

+ ✏️ 출고요청 상태 — 품목 수량을 직접 고치거나 [×]로 삭제할 수 있어요. 저장은 자동. +

+ )} +
- + + {editable && } @@ -297,6 +334,7 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: { 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" : ""; + const canEditItem = editable && !isExtra; // ITEM 라인만 본인이 수정 가능 return ( @@ -307,12 +345,23 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: { - + + {editable && ( + + )} ); })} @@ -334,10 +383,26 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: { {editable && (
- ※ 출고요청 상태이므로 [주문 취소] 가능합니다. 수량 수정·재요청은 주문 취소 후 [새 발주]로 다시 작성하세요. + ※ 출고요청 상태 — 품목 수량 수정 · 품목 삭제 · 주문 취소 모두 가능합니다. 택배/용차 라인은 모모 담당자가 조정합니다.
)} ); } + +function QtyInput({ initial, onSave }: { initial: number; onSave: (qty: number) => void }) { + const [v, setV] = useState(initial); + useEffect(() => { setV(initial); }, [initial]); + return ( + setV(Number(e.target.value))} + onBlur={() => { if (v !== initial && v > 0) onSave(v); }} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + className="w-full h-6 px-1 border border-slate-300 rounded text-[11px] text-right tabular-nums bg-white" + /> + ); +} diff --git a/src/app/api/m/orders/items/update/route.ts b/src/app/api/m/orders/items/update/route.ts new file mode 100644 index 0000000..bbcf62c --- /dev/null +++ b/src/app/api/m/orders/items/update/route.ts @@ -0,0 +1,150 @@ +// 발주 품목(ITEM) 라인 수량 변경/삭제 — 본인 또는 관리자, REQUESTED 상태에서만. +// 단가는 변경 불가 (momo_items.unit_price 기준 그대로). +// 변경 후 momo_orders 합계 자동 재집계. +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { requireMomoUser } from "@/lib/momo-guard"; +import { calcLine } from "@/lib/momo-pricing"; + +interface LineUpdate { + objid: string; // 라인 OBJID + qty?: number; // 새 수량 (undefined 면 변경 안 함) + delete?: boolean; // true 면 삭제 +} + +export async function POST(req: NextRequest) { + const r = await requireMomoUser(); + if (r instanceof NextResponse) return r; + + const body = await req.json().catch(() => ({})); + const { orderObjid, lines } = body as { orderObjid?: string; lines?: LineUpdate[] }; + if (!orderObjid || !Array.isArray(lines) || lines.length === 0) { + return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 }); + } + const isAdmin = r.user.isAdmin || r.user.role === "ADMIN" || r.user.userType === "A"; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const orderRes = await client.query( + `SELECT customer_objid, status FROM momo_orders WHERE objid = $1 FOR UPDATE`, + [orderObjid] + ); + if (orderRes.rowCount === 0) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 }); + } + const order = orderRes.rows[0]; + if (!isAdmin && order.customer_objid !== r.user.objid) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 }); + } + if (order.status !== "REQUESTED") { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "출고요청 상태에서만 수정할 수 있습니다." }, { status: 400 }); + } + + for (const ln of lines) { + // 라인 조회 + ITEM 종류 검증 + const lineRes = await client.query( + `SELECT OI.objid, OI.item_objid, OI.unit_price, OI.qty, OI.is_tax_free, + COALESCE(OI.kind,'ITEM') AS kind, + I.item_name, I.max_order_qty, + COALESCE(( + SELECT SUM(S.qty) FROM momo_stocks S + JOIN momo_warehouses W ON S.wh_objid = W.objid + WHERE S.item_objid = OI.item_objid AND COALESCE(W.is_del,'N') != 'Y' + ), 0) AS stock_qty + FROM momo_order_items OI + LEFT JOIN momo_items I ON I.objid = OI.item_objid + WHERE OI.objid = $1 AND OI.order_objid = $2`, + [ln.objid, orderObjid] + ); + if (lineRes.rowCount === 0) continue; + const cur = lineRes.rows[0]; + if (cur.kind !== "ITEM") { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "택배/용차 라인은 이 화면에서 수정할 수 없습니다." }, { status: 400 }); + } + + if (ln.delete) { + await client.query(`DELETE FROM momo_order_items WHERE objid = $1`, [ln.objid]); + continue; + } + + const newQty = Number(ln.qty); + if (!Number.isFinite(newQty) || newQty <= 0) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: "수량은 1 이상이어야 합니다." }, { status: 400 }); + } + + // 재고 / 한도 검증 (관리자가 아니고 unlimited_qty 권한 없으면) + if (!isAdmin) { + const stock = Number(cur.stock_qty); + if (newQty > stock) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: `${cur.item_name} — 재고(${stock})를 초과할 수 없습니다.` }, { status: 400 }); + } + const u = await client.query( + `SELECT COALESCE(unlimited_qty,'N') AS u FROM user_info WHERE user_id = $1`, + [r.user.objid] + ); + const unlimited = u.rows[0]?.u === "Y"; + if (!unlimited) { + const maxQ = cur.max_order_qty == null ? 0 : Number(cur.max_order_qty); + if (maxQ > 0 && newQty > maxQ) { + await client.query("ROLLBACK"); + return NextResponse.json({ success: false, message: `${cur.item_name} — 1회 발주 한도(${maxQ}) 초과` }, { status: 400 }); + } + } + } + + const isFree = cur.is_tax_free === "Y"; + const calc = calcLine({ unitPrice: Number(cur.unit_price), qty: newQty, isTaxFree: isFree }); + await client.query( + `UPDATE momo_order_items SET + qty = $2, + supply_amount = $3, + vat_amount = $4, + total_amount = $5 + WHERE objid = $1`, + [ln.objid, newQty, calc.supplyAmount, calc.vatAmount, calc.totalAmount] + ); + } + + // 합계 재집계 + const sum = await client.query( + `SELECT + COALESCE(SUM(supply_amount), 0) AS supply, + COALESCE(SUM(vat_amount), 0) AS vat, + COALESCE(SUM(total_amount), 0) AS total, + COALESCE(SUM(CASE WHEN is_tax_free='Y' THEN supply_amount ELSE 0 END), 0) AS taxfree, + COALESCE(SUM(CASE WHEN is_tax_free='N' THEN supply_amount ELSE 0 END), 0) AS taxable, + COALESCE(SUM(CASE WHEN kind='DELIVERY' THEN total_amount ELSE 0 END), 0) AS delivery, + COALESCE(SUM(CASE WHEN kind='CHARTER' THEN total_amount ELSE 0 END), 0) AS charter + FROM momo_order_items WHERE order_objid = $1`, + [orderObjid] + ); + const t = sum.rows[0]; + await client.query( + `UPDATE momo_orders SET + total_supply = $2, total_vat = $3, total_amount = $4, + total_taxfree = $5, total_taxable = $6, + total_delivery = $7, total_charter = $8, + update_date = NOW(), update_id = $9 + WHERE objid = $1`, + [orderObjid, t.supply, t.vat, t.total, t.taxfree, t.taxable, t.delivery, t.charter, + r.user.objid || r.user.userId] + ); + + await client.query("COMMIT"); + return NextResponse.json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("[orders/items/update]", err); + const msg = err instanceof Error ? err.message : "수정 오류"; + return NextResponse.json({ success: false, message: msg }, { status: 500 }); + } finally { + client.release(); + } +}
# 품명 구분수량수량 단가 공급가 세액 합계 비고
{idx + 1} {it.IS_TAX_FREE === "Y" ? "면세" : "과세"} {fmt(it.QTY)} + {canEditItem + ? updateItemLine(it.OBJID, q)} /> + : fmt(it.QTY)} + {fmt(it.UNIT_PRICE)} {fmt(it.SUPPLY_AMOUNT)} {it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)} {fmt(it.TOTAL_AMOUNT)} {it.REMARK || ""} + {canEditItem + ? + : 자동} +