feat(거래처 출고이력): 본인이 출고요청 상태 품목 수량/삭제 직접 수정
Deploy momo-erp / deploy (push) Successful in 51s

[새 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) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 00:38:23 +09:00
parent e86017c42a
commit 03838af90c
3 changed files with 228 additions and 6 deletions
+8 -1
View File
@@ -382,7 +382,14 @@
<ul>
<li><b>주문번호 또는 [보기] 클릭</b> → 거래명세표가 큰 창으로 떠요</li>
<li>거래명세표 위쪽에 <span class="btn btn-orange">📤 이미지 공유</span> <span class="btn btn-emerald">⬇ 엑셀 다운로드</span> 버튼이 있어 카톡 공유나 파일 저장 가능</li>
<li><b>출고요청 상태</b>인 주문은 <span class="btn btn-rose">🗑 주문 취소</span> 버튼이 떠 있어요. 수량 수정이 필요하면 취소 후 새로 작성하세요.</li>
<li><b>출고요청 상태</b>인 주문은 그 자리에서 <b>수정·삭제·취소</b>가 가능해요:
<ul>
<li>품목 행의 <b>수량 칸을 클릭</b>해서 새 수량 입력 → 다른 곳 누르면 자동 저장</li>
<li>품목 오른쪽 <b>[×]</b> 버튼 → 그 품목만 주문에서 삭제</li>
<li>위쪽 <span class="btn btn-rose">🗑 주문 취소</span> → 주문 전체 취소</li>
<li>택배·용차 라인은 모모 담당자가 조정합니다 (직접 수정 불가)</li>
</ul>
</li>
</ul>
<h4>주문 상태별 뜻</h4>
<table>
+70 -5
View File
@@ -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();
}}
/>
)}
</div>
);
}
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<HTMLDivElement>(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 }: {
</div>
</div>
<table className="w-full text-[11px] border border-slate-300 mt-3">
{editable && (
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
[×] . .
</p>
)}
<table className="w-full text-[11px] border border-slate-300 mt-2">
<thead className="bg-slate-100">
<tr>
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
<th className="border border-slate-300 px-1.5 py-1.5 text-left"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-12"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-14"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-16"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-20"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-24"></th>
{editable && <th className="border border-slate-300 px-1 py-1.5 w-8"></th>}
</tr>
</thead>
<tbody className="tabular-nums">
@@ -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 (
<tr key={it.OBJID || idx} className={kindBg}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
@@ -307,12 +345,23 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
<td className="border border-slate-300 px-1 py-0.5 text-right">
{canEditItem
? <QtyInput initial={Number(it.QTY)} onSave={(q) => updateItemLine(it.OBJID, q)} />
: fmt(it.QTY)}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.UNIT_PRICE)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.SUPPLY_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-[10px] text-slate-600">{it.REMARK || ""}</td>
{editable && (
<td className="border border-slate-300 px-1 py-1 text-center">
{canEditItem
? <button onClick={() => deleteItemLine(it.OBJID)} className="text-rose-500 hover:text-rose-700" title="삭제"><X size={12} /></button>
: <span className="text-slate-300 text-[10px]"></span>}
</td>
)}
</tr>
);
})}
@@ -334,10 +383,26 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
{editable && (
<div className="mt-4 pt-3 border-t border-slate-200 text-[11px] text-amber-700 bg-amber-50 rounded p-2">
[ ] . · [ ] .
<b> </b> · <b> </b> · <b> </b> . / .
</div>
)}
</div>
</div>
);
}
function QtyInput({ initial, onSave }: { initial: number; onSave: (qty: number) => void }) {
const [v, setV] = useState(initial);
useEffect(() => { setV(initial); }, [initial]);
return (
<input
type="number"
min={1}
value={v}
onChange={(e) => 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"
/>
);
}
+150
View File
@@ -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();
}
}