Files
distribution_erp/src/app/api/m/orders/save/route.ts
T
chpark d25db4a023
Deploy momo-erp / deploy (push) Successful in 2m26s
feat(orders): admin 수기 발주 작성 — 거래처 대신 명의로 등록
전화 요청 등 시 admin 이 거래처를 대신해 발주를 작성할 수 있도록.

- /m/admin/orders 헤더에 '수기 발주' 버튼 + SearchableSelect 거래처 picker
  → 선택 후 /m/orders/new?customerObjid=momoNNN 로 이동
- /m/orders/new 가 query param customerObjid 받음:
  · admin 일 때만 활성 (USER 가 query 박아도 무시)
  · 상단 배너에 거래처명 표시 + 취소 링크
  · save 호출 시 body 에 customerObjid 포함
- /api/m/orders/save: admin 이 body.customerObjid 명시하면 그걸로
  발주 INSERT (supplier_branch snapshot 도 해당 거래처 기준)
2026-05-14 16:10:05 +09:00

263 lines
11 KiB
TypeScript

// 출고요청서 작성 (대리점) — 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<string> {
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");
}