diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 0d40d27a..c7f2e0aa 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -324,6 +324,41 @@ export async function create(req: AuthenticatedRequest, res: Response) { WHERE id = $2 AND company_code = $3`, [outQtyNum, detailId, companyCode], ); + + // 수주 자동 상태 전이: 같은 order_no의 모든 detail이 완전 출고되면 master/detail 모두 'COMPLETED'. + const orderNoRes = await client.query( + `SELECT order_no FROM sales_order_detail WHERE id = $1 AND company_code = $2`, + [detailId, companyCode], + ); + const orderNo = orderNoRes.rows[0]?.order_no; + if (orderNo) { + const sumRes = await client.query( + `SELECT + SUM(COALESCE(NULLIF(qty,'')::numeric, 0)) AS total_qty, + SUM(COALESCE(NULLIF(ship_qty,'')::numeric, 0)) AS total_ship + FROM sales_order_detail + WHERE order_no = $1 AND company_code = $2`, + [orderNo, companyCode], + ); + const totalQty = Number(sumRes.rows[0]?.total_qty || 0); + const totalShip = Number(sumRes.rows[0]?.total_ship || 0); + if (totalQty > 0 && totalShip >= totalQty) { + await client.query( + `UPDATE sales_order_mng + SET status = 'COMPLETED', updated_date = NOW() + WHERE order_no = $1 AND company_code = $2 + AND COALESCE(UPPER(status), '') <> 'CANCELED'`, + [orderNo, companyCode], + ); + await client.query( + `UPDATE sales_order_detail + SET status = 'COMPLETED', updated_date = NOW() + WHERE order_no = $1 AND company_code = $2 + AND COALESCE(UPPER(status), '') <> 'CANCELED'`, + [orderNo, companyCode], + ); + } + } } // shipment_instruction master status 자동 전환 (입고의 purchase_detail → purchase_order_mng 패턴) diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index 14f28eba..e2b4779d 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -217,9 +217,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { try { await client.query("BEGIN"); - // ─── 사전 가드: 대상 수주 status='CONFIRMED' 검증 (TASK:ERP-047) ─── - // items[*].salesOrderId / detailId 로부터 sales_order_mng 의 status 를 조회. - // WAITING/CANCELED/COMPLETED 1건이라도 있으면 전체 트랜잭션 실패. + // ─── 사전 가드: COMPLETED/CANCELED 상태만 차단 (자동 상태 전이 정책: 등록→출하계획단계→완료) ─── + // WAITING/PLANNING 등은 모두 통과. 신규 등록 직후의 수주에서도 출하지시 작성 허용. { const masterIds = Array.from( new Set( @@ -259,24 +258,17 @@ export async function save(req: AuthenticatedRequest, res: Response) { statusRows.push(...r.rows); } - const blocked: { waiting: string[]; canceled: string[]; completed: string[] } = { - waiting: [], + const blocked: { canceled: string[]; completed: string[] } = { canceled: [], completed: [], }; for (const row of statusRows) { const st = (row.status || "").toUpperCase(); - if (st === "WAITING") blocked.waiting.push(row.order_no); - else if (st === "CANCELED") blocked.canceled.push(row.order_no); + if (st === "CANCELED") blocked.canceled.push(row.order_no); else if (st === "COMPLETED") blocked.completed.push(row.order_no); } const messages: string[] = []; - if (blocked.waiting.length > 0) { - messages.push( - `수주번호 ${blocked.waiting.join(", ")}은(는) 확정 상태가 아닙니다. 먼저 수주를 확정해주세요.` - ); - } if (blocked.canceled.length > 0) { messages.push( `취소된 수주에는 출하지시를 등록할 수 없습니다 (수주번호: ${blocked.canceled.join(", ")}).` @@ -432,7 +424,9 @@ export async function save(req: AuthenticatedRequest, res: Response) { const candidateIds = Array.from(masterIds); if (candidateIds.length > 0) { - // 3) 자동완료 가능 수주 ID 집계 — CANCELED 제외 모든 상세가 COMPLETED 출하지시 detail 에 커버되어야 함 + // 3) 자동완료 가능 수주 ID 집계 — CANCELED/이미 COMPLETED 인 detail 은 검사 제외. + // 나머지 detail 은 COMPLETED 출하지시에 매핑되어야 함. + // (이미 COMPLETED 인 detail 까지 매핑을 요구하면, 옛 정책에서 직접 COMPLETED 처리된 행이 차단 사유가 됨) const eligibleRes = await client.query( ` SELECT m.id, m.order_no @@ -445,7 +439,7 @@ export async function save(req: AuthenticatedRequest, res: Response) { FROM sales_order_detail d WHERE d.order_no = m.order_no AND d.company_code = m.company_code - AND COALESCE(d.status,'WAITING') <> 'CANCELED' + AND COALESCE(UPPER(d.status),'WAITING') NOT IN ('CANCELED','COMPLETED') AND NOT EXISTS ( SELECT 1 FROM shipment_instruction_detail sid diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 01e7ece5..fae667a4 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -631,13 +631,15 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); const savedPlans = []; - // ─── 사전 가드: 대상 수주들의 status='CONFIRMED' 검증 (TASK:ERP-047) ─── - // detail/master 모두 sales_order_mng.status를 기준으로 검증. - // WAITING/CANCELED/COMPLETED 1건이라도 포함되면 전체 트랜잭션 실패. + // ─── 사전 가드: COMPLETED/CANCELED 상태만 차단 (자동 상태 전이 도입 후 정책 변경) ─── + // 신규 등록('' 또는 'WAITING' 등) → 출하계획 작성 허용. 출하계획 작성 시 자동으로 'PLANNING'으로 전이. const validSourceIds = plans .filter((p: any) => p?.sourceId && Number(p?.planQty) > 0) .map((p: any) => String(p.sourceId)); + // 출하계획 작성 후 자동 PLANNING 전이용 — 영향받은 master id 모음 + const affectedMasterIds = new Set(); + if (validSourceIds.length > 0) { let statusRows: Array<{ id: number; order_no: string; status: string | null }>; if (detectedSource === "detail") { @@ -661,25 +663,19 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { statusRows = r.rows; } - const blocked: { waiting: string[]; canceled: string[]; completed: string[] } = { - waiting: [], + const blocked: { canceled: string[]; completed: string[] } = { canceled: [], completed: [], }; for (const row of statusRows) { const st = (row.status || "").toUpperCase(); - if (st === "WAITING") blocked.waiting.push(row.order_no); - else if (st === "CANCELED") blocked.canceled.push(row.order_no); + if (st === "CANCELED") blocked.canceled.push(row.order_no); else if (st === "COMPLETED") blocked.completed.push(row.order_no); - // 그 외(CONFIRMED 또는 그룹A 한글값 등)는 통과 + // 그 외(빈값/WAITING/CONFIRMED/PLANNING 등)는 통과 + if (row.id) affectedMasterIds.add(Number(row.id)); } const messages: string[] = []; - if (blocked.waiting.length > 0) { - messages.push( - `수주번호 ${blocked.waiting.join(", ")}은(는) 확정 상태가 아닙니다. 먼저 수주를 확정해주세요.` - ); - } if (blocked.canceled.length > 0) { messages.push( `취소된 수주에는 출하계획을 등록할 수 없습니다 (수주번호: ${blocked.canceled.join(", ")}).` @@ -808,6 +804,33 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { } } + // 자동 상태 전이: 출하계획이 생성된 수주는 PLANNING으로 변경 (COMPLETED/CANCELED 제외). + // sales_order_mng + 해당 master의 모든 sales_order_detail.status 동시 갱신. + if (affectedMasterIds.size > 0) { + const masterIdArr = [...affectedMasterIds]; + await client.query( + `UPDATE sales_order_mng + SET status = 'PLANNING', + updated_date = NOW() + WHERE id = ANY($1::int[]) + AND company_code = $2 + AND COALESCE(UPPER(status), '') NOT IN ('COMPLETED', 'CANCELED')`, + [masterIdArr, companyCode] + ); + await client.query( + `UPDATE sales_order_detail + SET status = 'PLANNING', + updated_date = NOW() + WHERE order_no IN ( + SELECT order_no FROM sales_order_mng + WHERE id = ANY($1::int[]) AND company_code = $2 + ) + AND company_code = $2 + AND COALESCE(UPPER(status), '') NOT IN ('COMPLETED', 'CANCELED')`, + [masterIdArr, companyCode] + ); + } + await client.query("COMMIT"); logger.info("출하계획 일괄 저장 완료", { diff --git a/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx index 05c74130..5c2867ee 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx @@ -134,8 +134,6 @@ export default function InventoryStatusPage() { const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); - // 재고 없는 품목 표시 여부 - const [showMissingItems, setShowMissingItems] = useState(false); // 창고 목록 (조정 모달에서 사용) const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]); @@ -251,43 +249,13 @@ export default function InventoryStatusPage() { }; }); - // 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가 - if (showMissingItems) { - const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean)); - const missingRows = items - .filter((i: any) => { - const code = i.item_number || i.item_code; - return code && !existingCodes.has(code); - }) - .map((i: any) => { - const code = i.item_number || i.item_code; - const rawUnit = i.inventory_unit || ""; - return { - id: `missing-${code}`, - item_code: code, - item_name: i.item_name || "", - spec: i.size || "", - warehouse_code: "", - warehouse_name: "", - location_code: "", - current_qty: "0", - safety_qty: "", - unit: resolve("item_inventory_unit", rawUnit) || rawUnit, - status: "미등록", - _isLow: false, - _isMissing: true, - }; - }); - setStockItems([...data, ...missingRows]); - } else { - setStockItems(data); - } + setStockItems(data); } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters, showMissingItems]); + }, [categoryOptions, searchFilters]); useEffect(() => { fetchStock(); @@ -593,13 +561,6 @@ export default function InventoryStatusPage() { {filteredStockItems.length}건 - {remain ? remain.toLocaleString() : ""}; }; + } else if (col.key === "unit_price") { + base.render = (_v: any, row: any) => { + const curr = row.currency || "KRW"; + if (!row.unit_price) return ""; + return ( + + {curr !== "KRW" && {curr}} + {Number(row.unit_price).toLocaleString()} + + ); + }; + } else if (col.key === "amount") { + base.render = (_v: any, row: any) => { + const curr = row.currency || "KRW"; + const rate = parseFloat(row.exchange_rate || "1") || 1; + const amt = Number(row.amount || 0); + if (!row.amount) return ""; + return ( + + + {curr !== "KRW" && {curr}} + {amt.toLocaleString()} + + {curr !== "KRW" && ( + + ₩ {Math.round(amt * rate).toLocaleString()} + + )} + + ); + }; } else if (numCols.has(col.key)) { const k = col.key; base.render = (_v: any, row: any) => ( @@ -243,7 +274,7 @@ export default function PurchaseOrderPage() { // 카테고리 로드 useEffect(() => { const loadCategories = async () => { - const catColumns = ["input_mode", "price_mode"]; + const catColumns = ["input_mode", "price_mode", "currency"]; const optMap: Record = {}; const flatten = (vals: any[]): { code: string; label: string }[] => { const result: { code: string; label: string }[] = []; @@ -388,6 +419,9 @@ export default function PurchaseOrderPage() { supplier_name: master?.supplier_name || "", order_date: master?.order_date || "", memo: row.memo || master?.memo || "", + // 통화/환율 — 목록 그리드에서 단가·금액 통화 표시용 + currency: master?.currency || "KRW", + exchange_rate: master?.exchange_rate || "1", }; }); @@ -420,6 +454,8 @@ export default function PurchaseOrderPage() { manager: user?.userId || "", order_date: new Date().toISOString().split("T")[0], status: "작성중", + currency: "KRW", + exchange_rate: "1", }); setDetailRows([]); setIsEditMode(false); @@ -894,6 +930,7 @@ export default function PurchaseOrderPage() { e.preventDefault()} > {isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"} @@ -977,6 +1014,50 @@ export default function PurchaseOrderPage() { +
+ + +
+
+ + setMasterForm((p) => ({ ...p, exchange_rate: e.target.value }))} + placeholder={(masterForm.currency || "KRW") === "KRW" ? "1" : "예: 1370"} + className="h-9" + disabled={isReadOnly || (masterForm.currency || "KRW") === "KRW"} + /> +
@@ -1103,18 +1184,44 @@ export default function PurchaseOrderPage() { return {row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}; case "remain_qty": return {row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}; - case "unit_price": + case "unit_price": { + const curr = masterForm.currency || "KRW"; return ( - + {isReadOnly ? ( - {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + + {curr !== "KRW" && {curr}} + {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} + ) : ( - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> +
+ {curr !== "KRW" && {curr}} + updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> +
)}
); - case "amount": - return {row.amount ? Number(row.amount).toLocaleString() : ""}; + } + case "amount": { + const curr = masterForm.currency || "KRW"; + const rate = parseFloat(masterForm.exchange_rate || "1") || 1; + const amtNum = Number(row.amount || 0); + return ( + +
+ + {curr !== "KRW" && {curr}} + {row.amount ? amtNum.toLocaleString() : ""} + + {curr !== "KRW" && row.amount && ( + + ₩ {Math.round(amtNum * rate).toLocaleString()} + + )} +
+
+ ); + } case "due_date": return ( @@ -1146,6 +1253,28 @@ export default function PurchaseOrderPage() { )} + {/* 합계 — 외화 합계 + KRW 환산 (KRW 외 통화일 때만 환산 라인 표시) */} + {detailRows.length > 0 && (() => { + const curr = masterForm.currency || "KRW"; + const rate = parseFloat(masterForm.exchange_rate || "1") || 1; + const totalAmt = detailRows.reduce((s: number, r: any) => s + (Number(r.amount) || 0), 0); + return ( +
+ 합계 +
+ + {curr !== "KRW" && {curr}} + {totalAmt.toLocaleString()} + + {curr !== "KRW" && ( + + ₩ {Math.round(totalAmt * rate).toLocaleString()} (환율 {rate.toLocaleString()}) + + )} +
+
+ ); + })()} {/* 비고 */} diff --git a/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx index 964e7e09..6ce50f62 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx @@ -189,7 +189,7 @@ export default function SupplierManagementPage() { }; const load = async () => { const optMap: Record = {}; - for (const col of ["division", "status"]) { + for (const col of ["division", "status", "region_type"]) { try { const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); @@ -625,10 +625,14 @@ export default function SupplierManagementPage() { }; // 폼 필드 변경 시 자동 포맷팅 + 실시간 검증 + // 해외(region_type=OVERSEAS) 업체의 전화/팩스/사무실 번호는 자동 하이픈/길이 검증을 건너뛰고 raw 그대로 저장. + const PHONE_LIKE_FIELDS = new Set(["contact_phone", "phone", "cell_phone", "fax", "fax_number", "office_number"]); const handleFormChange = (field: string, value: string) => { - const formatted = formatField(field, value); + const isOverseas = supplierForm.region_type === "OVERSEAS"; + const skipFormat = isOverseas && PHONE_LIKE_FIELDS.has(field); + const formatted = skipFormat ? value : formatField(field, value); setSupplierForm((prev) => ({ ...prev, [field]: formatted })); - const error = validateField(field, formatted); + const error = skipFormat ? null : validateField(field, formatted); setFormErrors((prev) => { const next = { ...prev }; if (error) next[field] = error; else delete next[field]; @@ -714,7 +718,12 @@ export default function SupplierManagementPage() { const handleSupplierSave = async () => { if (!supplierForm.supplier_name) { toast.error("공급업체명은 필수입니다."); return; } if (!supplierForm.status) { toast.error("상태는 필수입니다."); return; } - const errors = validateForm(supplierForm, ["contact_phone", "email", "business_number"]); + // 해외 업체는 전화번호/사업자번호 형식 검증 제외 (국가별 형식 다양) + const isOverseas = supplierForm.region_type === "OVERSEAS"; + const validateFields = isOverseas + ? ["email"] + : ["contact_phone", "email", "business_number"]; + const errors = validateForm(supplierForm, validateFields); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); @@ -1842,6 +1851,23 @@ export default function SupplierManagementPage() { +
+ + +
-
- - setMasterForm((p) => ({ ...p, status: v }))} - options={(() => { - const opts = (categoryOptions["status"] || []).length > 0 - ? categoryOptions["status"] - : [ - { code: "WAITING", label: "대기" }, - { code: "CONFIRMED", label: "확정" }, - { code: "CANCELED", label: "취소" }, - { code: "COMPLETED", label: "완료" }, - ]; - // 현재 값이 옵션에 없으면 임시 추가 (수정 시 카테고리에 없는 임의 코드 대비) - const cur = masterForm.status; - if (cur && !opts.some((o: any) => o.code === cur)) { - return [...opts, { code: cur, label: cur }]; - } - return opts; - })()} - placeholder="상태 선택" - /> -
+ {/* 상태는 자동 전이(등록 → 출하계획단계 → 완료) 정책이므로 입력 UI 노출하지 않음. + 취소는 수주 목록 툴바의 '취소' 버튼으로 수행. */}
- + setMasterForm((p) => ({ ...p, manager_id: v }))} + placeholder="담당자 선택" + options={categoryOptions["manager_id"] || []} + />
diff --git a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx index be97dbf7..64833b1d 100644 --- a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx @@ -1044,7 +1044,7 @@ export default function ShippingOrderPage() {
- +
@@ -1114,7 +1114,7 @@ export default function ShippingOrderPage() { 거래처 - 납기일 + 출하계획일 수량 @@ -1475,7 +1475,7 @@ export default function ShippingOrderPage() { 품명 - 납기일 + 출하계획일 출하수량