// 출고요청서 작성 (대리점) — 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"; import { getSupplierByBranch } from "@/lib/momo-branches"; interface InputItemLine { itemObjid: string; qty: number; } interface InputExtraLine { kind: "DELIVERY" | "CHARTER"; // v0.4 단가/수량 분리. 기존 amount 도 받아서 호환 (qty=1, unitPrice=amount). unitPrice?: number; qty?: number; amount?: number; label?: string; } export async function POST(req: NextRequest) { const r = await requireMomoUser(); if (r instanceof NextResponse) return r; const isAdmin = r.user.isAdmin === true || r.user.role === "ADMIN" || r.user.userType === "A"; let lines: InputItemLine[]; let extras: InputExtraLine[]; let memo: string | undefined; let customerObjid: string; try { const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string; customerObjid?: string }; lines = body.lines; extras = Array.isArray(body.extras) ? body.extras : []; memo = body.memo; // admin 만 customerObjid 명시 가능 (수기 발주 작성). USER 는 본인 ID 자동. if (isAdmin && body.customerObjid) { customerObjid = body.customerObjid; } else { customerObjid = r.user.objid || r.user.userId; } if (!customerObjid) { return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 }); } } catch { return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 }); } if (!Array.isArray(lines) || lines.length === 0) { return NextResponse.json({ success: false, message: "발주 품목을 추가하세요." }, { status: 400 }); } for (const ln of lines) { if (!ln?.itemObjid || !Number.isFinite(Number(ln.qty)) || Number(ln.qty) <= 0) { return NextResponse.json({ success: false, message: "품목/수량 형식이 올바르지 않습니다." }, { status: 400 }); } } // 택배/용차 라인 정규화: unitPrice + qty 우선, amount 는 폴백 const normExtras = extras.map((ex) => { const unitPrice = Number(ex.unitPrice ?? ex.amount ?? 0); const qty = Number(ex.qty ?? 1); return { kind: ex.kind, unitPrice, qty, label: ex.label }; }); for (const ex of normExtras) { if (ex.kind !== "DELIVERY" && ex.kind !== "CHARTER") { return NextResponse.json({ success: false, message: "택배/용차 라인 종류가 올바르지 않습니다." }, { status: 400 }); } if (!Number.isFinite(ex.unitPrice) || ex.unitPrice < 0 || !Number.isFinite(ex.qty) || ex.qty <= 0) { return NextResponse.json({ success: false, message: "택배/용차 단가/수량 형식이 올바르지 않습니다." }, { status: 400 }); } } try { const customerRow = await pool.query( `SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty, COALESCE(view_hidden, 'N') AS view_hidden, COALESCE(statement_branch, 'HQ') AS statement_branch FROM user_info WHERE user_id = $1`, [customerObjid] ); const unlimitedQty = customerRow.rows[0]?.unlimited_qty === "Y"; const viewHidden = customerRow.rows[0]?.view_hidden === "Y"; const customerBranch = customerRow.rows[0]?.statement_branch ?? "HQ"; // 출고요청 시점의 공급자(거래명세표) 정보를 snapshot — 이후 기준 명세표/사용자 // 설정을 바꿔도 이미 찍힌 명세표는 그대로 유지됨. const supplierSnap = await getSupplierByBranch(customerBranch); const itemIds = lines.map((l) => l.itemObjid); const placeholders = itemIds.map((_, i) => `$${i + 1}`).join(","); 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, 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 WHERE S.item_objid = I.objid AND COALESCE(W.is_del,'N') != 'Y' ), 0) AS stock_qty FROM momo_items I WHERE I.objid IN (${placeholders}) AND COALESCE(I.is_del, 'N') != 'Y'`, itemIds ); const itemMap = new Map(items.rows.map((row) => [row.objid as string, row])); const missing = lines.find((ln) => !itemMap.has(ln.itemObjid)); if (missing) { return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 }); } // 수량/숨김/택배 검증 let needsDelivery = false; for (const ln of lines) { const it = itemMap.get(ln.itemObjid)!; const stock = Number(it.stock_qty ?? 0); if (Number(ln.qty) > stock) { return NextResponse.json({ success: false, message: `${it.item_name} — 재고(${stock})를 초과할 수 없습니다.`, }, { status: 400 }); } if (!unlimitedQty) { const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty); if (maxQ > 0 && Number(ln.qty) > maxQ) { return NextResponse.json({ success: false, message: `${it.item_name} — 1회 발주 제한수량(${maxQ})을 초과할 수 없습니다.`, }, { status: 400 }); } } // 숨김 품목은 view_hidden 권한자만 발주 가능 if (it.is_hidden === "Y" && !viewHidden) { return NextResponse.json({ success: false, message: `${it.item_name} 은 발주 불가 품목입니다.`, }, { status: 400 }); } if (it.requires_delivery === "Y") needsDelivery = true; } // 택배 전용 품목이 있는데 택배 라인이 없으면 차단 const hasDeliveryLine = normExtras.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"; const calc = calcLine({ unitPrice: Number(it.unit_price), qty: ln.qty, isTaxFree: isFree }); return { seq: idx + 1, itemObjid: ln.itemObjid, itemName: it.item_name as string, unitPrice: Number(it.unit_price), qty: ln.qty, isTaxFree: isFree, ...calc, }; }); // 택배/용차 라인 — 단가×수량 = VAT포함 합계, 일반 과세 처리 let totalDelivery = 0; let totalCharter = 0; const extraEnriched = normExtras.map((ex, idx) => { const unitPrice = Math.round(ex.unitPrice); const qty = ex.qty; const calc = calcLine({ unitPrice, qty, isTaxFree: false }); if (ex.kind === "DELIVERY") totalDelivery += calc.totalAmount; if (ex.kind === "CHARTER") totalCharter += calc.totalAmount; return { seq: enriched.length + idx + 1, kind: ex.kind, label: ex.label?.trim() || (ex.kind === "DELIVERY" ? "택배비" : "용차비"), unitPrice, qty, isTaxFree: false, ...calc, }; }); const totals = sumTotals([ ...enriched, ...extraEnriched, ]); const client = await pool.connect(); try { await client.query("BEGIN"); await client.query( `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, supplier_branch, supplier_name, supplier_ceo, supplier_bank_account, supplier_phone, supplier_email, supplier_biz_no, supplier_address ) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12, $13,$14,$15,$16,$17,$18,$19,$20)`, [orderObjid, orderNo, customerObjid, totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable, totalDelivery, totalCharter, memo ?? null, customerObjid, supplierSnap.CODE, supplierSnap.NAME, supplierSnap.CEO, supplierSnap.BANK_ACCOUNT, supplierSnap.PHONE, supplierSnap.EMAIL, supplierSnap.BIZ_NO, supplierSnap.ADDRESS] ); 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, 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,$5,'N',$6,$7,$8,$9,$10,$11)`, [createObjectId(), orderObjid, ex.label, ex.unitPrice, ex.qty, 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) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } catch (err) { console.error("[order/save]", err); const msg = err instanceof Error ? err.message : "발주 저장 중 오류가 발생했습니다."; return NextResponse.json({ success: false, message: msg }, { status: 500 }); } } async function genOrderNo(): Promise { const today = new Date(); const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; const prefix = `ORD-${ymd}-`; const row = await queryOne<{ MAX_NO: string }>( `SELECT COALESCE(MAX(order_no), '') AS "MAX_NO" FROM momo_orders WHERE order_no LIKE $1 || '%'`, [prefix] ); const last = row?.MAX_NO ?? ""; const lastNum = last ? Number(last.replace(prefix, "")) || 0 : 0; return prefix + String(lastNum + 1).padStart(4, "0"); }