d25db4a023
Deploy momo-erp / deploy (push) Successful in 2m26s
전화 요청 등 시 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 도 해당 거래처 기준)
263 lines
11 KiB
TypeScript
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");
|
|
}
|