From 9f9be20e34c4ae460ddfa7433ecfc55ee45874b2 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 22 May 2026 09:59:20 +0900 Subject: [PATCH] Enhance Item Inspection and Outbound Functionality - Added `apply_process_name` to the item inspection controller, allowing for better clarity in process identification by joining with the `process_mng` table. - Updated the outbound controller to include additional delivery details, such as `delivery_destination_name` and `customer_name`, with fallback logic for improved data accuracy. - Enhanced the query logic to ensure proper handling of delivery addresses and customer information, improving the overall data retrieval process. (TASK: ERP-XXX) --- .../controllers/itemInspectionController.ts | 26 ++- .../src/controllers/outboundController.ts | 52 ++++- .../src/controllers/packagingController.ts | 8 +- .../controllers/popProductionController.ts | 32 ++- .../controllers/shippingOrderController.ts | 12 +- .../src/controllers/shippingPlanController.ts | 13 +- .../controllers/workInstructionController.ts | 28 ++- .../src/services/productionPlanService.ts | 219 +++++++++++++----- .../src/services/salesOrderBulkService.ts | 44 +++- .../COMPANY_9/logistics/outbound/page.tsx | 6 +- .../COMPANY_9/logistics/packaging/page.tsx | 25 +- .../_components/outbound/SalesOutbound.tsx | 15 +- .../_components/production/ProcessWork.tsx | 69 ++++-- .../WorkInstructionApplyModal.tsx | 32 ++- .../production/cutting-plan/page.tsx | 19 +- .../production/plan-management/page.tsx | 51 ++++ .../COMPANY_9/purchase/supplier/page.tsx | 12 +- .../quality/item-inspection/page.tsx | 7 +- .../(main)/COMPANY_9/sales/customer/page.tsx | 22 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 136 ++++++++++- .../COMPANY_9/sales/shipping-order/page.tsx | 3 + .../COMPANY_9/sales/shipping-plan/page.tsx | 2 + frontend/lib/api/itemInspection.ts | 1 + frontend/lib/api/outbound.ts | 6 + frontend/lib/api/production.ts | 6 + frontend/lib/api/shipping.ts | 1 + .../components/DetailFormModal.tsx | 80 +++++-- frontend/lib/utils/validation.ts | 6 + 28 files changed, 781 insertions(+), 152 deletions(-) diff --git a/backend-node/src/controllers/itemInspectionController.ts b/backend-node/src/controllers/itemInspectionController.ts index 0f8bb734..ab50a7a3 100644 --- a/backend-node/src/controllers/itemInspectionController.ts +++ b/backend-node/src/controllers/itemInspectionController.ts @@ -18,6 +18,7 @@ interface InspectionRow { inspection_item_name: string | null; inspection_method: string | null; apply_process: string | null; + apply_process_name: string | null; classification: string | null; pass_criteria: string | null; upper_limit: string | null; @@ -96,16 +97,25 @@ export async function getGroupedList(req: AuthenticatedRequest, res: Response) { } // 3) 페이지 item_code들의 모든 검사항목 row + // 적용공정(apply_process)은 공정 코드(P001 등)이므로 process_mng JOIN으로 공정명 해석. + // 공정 매핑이 깨졌거나 코드가 없으면 apply_process_name 은 NULL → 프론트에서 코드 fallback. const detailQuery = ` SELECT - id, item_code, item_name, inspection_type, inspection_standard, inspection_standard_id, - inspection_item_name, inspection_method, apply_process, classification, - pass_criteria, upper_limit, lower_limit, is_required, is_active, manager_id, memo, - sort_order, change_record, created_date, updated_date - FROM item_inspection_info - WHERE company_code = $1 - AND item_code = ANY($2::text[]) - ORDER BY item_code, sort_order, created_date; + iii.id, iii.item_code, iii.item_name, iii.inspection_type, iii.inspection_standard, + iii.inspection_standard_id, iii.inspection_item_name, iii.inspection_method, + iii.apply_process, + pm.process_name AS apply_process_name, + iii.classification, + iii.pass_criteria, iii.upper_limit, iii.lower_limit, iii.is_required, iii.is_active, + iii.manager_id, iii.memo, iii.sort_order, iii.change_record, + iii.created_date, iii.updated_date + FROM item_inspection_info iii + LEFT JOIN process_mng pm + ON pm.process_code = iii.apply_process + AND pm.company_code = iii.company_code + WHERE iii.company_code = $1 + AND iii.item_code = ANY($2::text[]) + ORDER BY iii.item_code, iii.sort_order, iii.created_date; `; const detailRes = await pool.query(detailQuery, [companyCode, itemCodes]); const rowsByItem: Record = {}; diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 172fefd6..93940ebb 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -75,11 +75,45 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const query = ` SELECT om.*, - wh.warehouse_name + wh.warehouse_name, + -- 납품처 (TASK:ERP-097): 출고 자체 납품처 정보 우선, + -- 없으면 출처 출하지시(shipment_instruction_detail → shipment_instruction)에서 폴백 + COALESCE( + NULLIF(om.delivery_destination, ''), + NULLIF(om.delivery_address, ''), + NULLIF(sid.delivery_address, ''), + NULLIF(si.delivery_address, ''), + '' + ) AS delivery_destination_name, + -- 거래처명 (TASK:ERP-098 항목20): 출고 행 customer_name 우선, + -- 없으면 customer_mng JOIN, 매핑 깨지면 코드 fallback + COALESCE( + NULLIF(om.customer_name, ''), + NULLIF(c.customer_name, ''), + NULLIF(om.customer_code, ''), + '' + ) AS customer_name, + -- 품목 규격 (TASK:ERP-098 항목19): 유리업종 가로/세로/두께 (item_info JOIN) + COALESCE(ii.width::text, '') AS width, + COALESCE(ii.height::text, '') AS height, + COALESCE(ii.thickness::text, '') AS thickness FROM outbound_mng om LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code + LEFT JOIN shipment_instruction_detail sid + ON om.source_table = 'shipment_instruction_detail' + AND om.source_id = sid.id::text + AND om.company_code = sid.company_code + LEFT JOIN shipment_instruction si + ON sid.instruction_id = si.id + AND sid.company_code = si.company_code + LEFT JOIN customer_mng c + ON om.customer_code = c.customer_code + AND om.company_code = c.company_code + LEFT JOIN item_info ii + ON om.item_code = ii.item_number + AND om.company_code = ii.company_code ${whereClause} ORDER BY om.created_date DESC `; @@ -581,7 +615,17 @@ export async function getShipmentInstructions( let paramIdx = 2; if (customer) { - conditions.push(`si.partner_id = $${paramIdx}`); + // 출하지시(shipment_instruction.partner_id)에는 거래처코드 또는 거래처명이 + // 혼재 저장될 수 있음(출하지시 등록 화면이 partner_code 미확보 시 거래처명을 + // fallback 저장). POP 출고는 거래처코드로 조회하므로 코드/명 양쪽 + 거래처 + // 마스터의 코드/명 모두로 매칭해 누락을 방지한다. + conditions.push( + `( + si.partner_id = $${paramIdx} + OR c.customer_code = $${paramIdx} + OR c.customer_name = $${paramIdx} + )`, + ); params.push(customer); paramIdx++; } @@ -632,7 +676,7 @@ export async function getShipmentInstructions( ON si.id = sid.instruction_id AND si.company_code = sid.company_code LEFT JOIN customer_mng c - ON si.partner_id = c.customer_code + ON (si.partner_id = c.customer_code OR si.partner_id = c.customer_name) AND si.company_code = c.company_code LEFT JOIN sales_order_detail sod ON sod.id::text = sid.detail_id::text @@ -641,7 +685,7 @@ export async function getShipmentInstructions( ON ii.item_number = sid.item_code AND ii.company_code = sid.company_code LEFT JOIN customer_item_mapping cim - ON cim.customer_id = si.partner_id + ON cim.customer_id = COALESCE(c.customer_code, si.partner_id) AND cim.company_code = sid.company_code AND (cim.item_id = sid.item_code OR cim.item_id = ii.id::text) WHERE ${conditions.join(" AND ")} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index 8a98c256..a5e443de 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -237,8 +237,8 @@ export async function createPkgUnitItem( } const result = await pool.query( - `INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) - VALUES ($1,$2,$3,$4,$5) + `INSERT INTO pkg_unit_item (id, company_code, pkg_code, item_number, pkg_qty, writer, created_date) + VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5, NOW()) RETURNING *`, [companyCode, pkg_code, item_number, pkg_qty, req.user!.userId] ); @@ -513,8 +513,8 @@ export async function createLoadingUnitPkg( } const result = await pool.query( - `INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) - VALUES ($1,$2,$3,$4,$5,$6) + `INSERT INTO loading_unit_pkg (id, company_code, loading_code, pkg_code, max_load_qty, load_method, writer, created_date) + VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5,$6, NOW()) RETURNING *`, [companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId] ); diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 0978e0e8..11671a61 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3631,6 +3631,11 @@ export const getChecklistItems = async ( .json({ success: false, message: "processId 필수" }); } + // 검사기준 마스터(inspection_standard) JOIN — 판단기준(judgment_criteria) 전달용. + // - 체크리스트 row 의 inspection_code 는 비어있는 경우가 많아(템플릿 미연결) + // 1차로 검사항목명(detail_content/item_title) ↔ inspection_standard.inspection_item 로 매칭, + // 2차로 inspection_code 매칭을 fallback 으로 둔다. + // - 매칭된 검사기준의 unit/lower_limit/upper_limit 도 보조 노출(체크리스트 row 자체값 우선). const result = await pool.query( `SELECT pwr.id, @@ -3647,7 +3652,7 @@ export const getChecklistItems = async ( pwr.is_required, pwr.inspection_code, pwr.inspection_method, - pwr.unit, + COALESCE(NULLIF(pwr.unit, ''), ist.unit) AS unit, pwr.lower_limit, pwr.upper_limit, pwr.input_type, @@ -3665,11 +3670,28 @@ export const getChecklistItems = async ( pwr.group_paused_at, pwr.group_total_paused_time, pwr.group_completed_at, - ist.judgment_criteria + ist.judgment_criteria, + ist.selection_options, + ist.inspection_code AS matched_inspection_code FROM process_work_result pwr - LEFT JOIN inspection_standard ist - ON pwr.inspection_code = ist.inspection_code - AND pwr.company_code = ist.company_code + LEFT JOIN LATERAL ( + SELECT s.judgment_criteria, s.unit, s.inspection_code, s.selection_options + FROM inspection_standard s + WHERE s.company_code = pwr.company_code + AND ( + (NULLIF(pwr.inspection_code, '') IS NOT NULL + AND s.inspection_code = pwr.inspection_code) + -- detail_content 에는 UI 구분자(" |") 가 함께 저장된 경우가 있어 양끝 공백/'|' 제거 후 비교 + OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, '')) + = TRIM(BOTH ' |' FROM COALESCE(pwr.detail_content, '')) + OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, '')) + = TRIM(BOTH ' |' FROM COALESCE(pwr.item_title, '')) + ) + -- inspection_code 정확매칭을 최우선, 그 다음 항목명 매칭 + ORDER BY CASE WHEN NULLIF(pwr.inspection_code, '') IS NOT NULL + AND s.inspection_code = pwr.inspection_code THEN 0 ELSE 1 END + LIMIT 1 + ) ist ON true WHERE pwr.work_order_process_id = $1 AND pwr.company_code = $2 ORDER BY diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index e5c71c79..04aff996 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -59,6 +59,9 @@ export async function getList(req: AuthenticatedRequest, res: Response) { SELECT si.*, COALESCE(c.customer_name, '') AS customer_name, + -- 납품처 (TASK:ERP-097): 출하지시 헤더의 납품주소 우선, + -- 없으면 거래처 기본 납품처(delivery_destination)로 폴백 + COALESCE(NULLIF(si.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name, COALESCE( json_agg( json_build_object( @@ -91,8 +94,15 @@ export async function getList(req: AuthenticatedRequest, res: Response) { WHERE item_number = sid.item_code AND company_code = si.company_code LIMIT 1 ) i ON true + LEFT JOIN LATERAL ( + SELECT destination_name FROM delivery_destination + WHERE customer_code = si.partner_id AND company_code = si.company_code + ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END, + created_date ASC NULLS LAST + LIMIT 1 + ) dd ON true ${where} - GROUP BY si.id, c.customer_name + GROUP BY si.id, c.customer_name, dd.destination_name ORDER BY si.created_date DESC `; diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 31f6c3e8..01e7ece5 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -257,7 +257,10 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty, COALESCE(NULLIF(d.width, ''), i.width::text, '') AS width, COALESCE(NULLIF(d.height, ''), i.height::text, '') AS height, - COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness + COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness, + -- 납품처 (TASK:ERP-097): 수주상세/마스터의 납품장소 텍스트 우선, + -- 없으면 거래처 기본 납품처(delivery_destination)로 폴백 + COALESCE(NULLIF(d.delivery_location, ''), NULLIF(m.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name FROM shipment_plan sp LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code @@ -274,6 +277,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LEFT JOIN customer_mng c ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code + LEFT JOIN LATERAL ( + SELECT destination_name FROM delivery_destination + WHERE customer_code = COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) + AND company_code = sp.company_code + ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END, + created_date ASC NULLS LAST + LIMIT 1 + ) dd ON true ${whereClause} ORDER BY sp.created_date DESC `; diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 26689e0b..23f6e8c9 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -388,10 +388,36 @@ export async function save(req: AuthenticatedRequest, res: Response) { wiId = insertRes.rows[0].id; } + // 품목별 기본 라우팅 버전 자동 해석 (TASK:ERP-node-104) + // 절단계획 적용 등으로 item.routing 이 비어 들어오는 경로를 위해, + // 해당 품목의 활성(기본) 라우팅 버전을 조회해 자동 세팅한다. + // - is_default=true 우선, 없으면 최신(created_date DESC) 1건 + // - 품목에 라우팅 버전이 0건이면 빈값 유지(작업지시 생성은 차단하지 않음) + const routingCache = new Map(); + const resolveDefaultRouting = async (itemCode: string): Promise => { + const key = String(itemCode || "").trim(); + if (!key) return null; + if (routingCache.has(key)) return routingCache.get(key) || null; + const rv = await client.query( + `SELECT id FROM item_routing_version + WHERE item_code = $1 AND company_code = $2 + ORDER BY COALESCE(is_default, false) DESC, created_date DESC + LIMIT 1`, + [key, companyCode] + ); + const resolved = rv.rowCount && rv.rowCount > 0 ? rv.rows[0].id : null; + routingCache.set(key, resolved); + return resolved; + }; + let totalQty = 0; let firstRouting: string | null = null; for (const item of items) { - const itemRouting = item.routing || null; + // 프론트가 명시한 routing 우선, 없으면 품목 기본 라우팅 버전 자동 조회 + let itemRouting: string | null = item.routing || null; + if (!itemRouting) { + itemRouting = await resolveDefaultRouting(item.itemNumber || item.itemCode || item.partCode || ""); + } if (!firstRouting && itemRouting) firstRouting = itemRouting; totalQty += Number(item.qty || 0); await client.query( diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 48b008f9..3a42ee6d 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -12,9 +12,15 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; // ─── 근무일 계산 헬퍼 (토/일 제외) ─── +// +// ⚠️ 모든 날짜 연산은 UTC 기준으로 통일한다. (TASK:ERP-node-107) +// `new Date("2026-05-21")` 는 UTC 자정으로 파싱되는데, +// `new Date(); setHours(0,0,0,0)` 는 로컬(KST) 자정 → UTC 로는 전날이 됨. +// 둘을 섞으면 `.toISOString()` 직렬화 시 스케줄 날짜가 하루 과거로 밀린다. +// → getUTC*/setUTC* 와 todayUTC() 로 기준을 일치시켜 드리프트 제거. function isWeekend(date: Date): boolean { - const day = date.getDay(); + const day = date.getUTCDay(); return day === 0 || day === 6; } @@ -22,7 +28,7 @@ function isWeekend(date: Date): boolean { function skipWeekend(date: Date, direction: "forward" | "backward"): Date { const d = new Date(date); while (isWeekend(d)) { - d.setDate(d.getDate() + (direction === "forward" ? 1 : -1)); + d.setUTCDate(d.getUTCDate() + (direction === "forward" ? 1 : -1)); } return d; } @@ -34,12 +40,79 @@ function addWorkingDays(date: Date, workingDays: number): Date { const step = workingDays > 0 ? 1 : -1; let remaining = Math.abs(workingDays); while (remaining > 0) { - d.setDate(d.getDate() + step); + d.setUTCDate(d.getUTCDate() + step); if (!isWeekend(d)) remaining--; } return d; } +// 오늘(UTC 자정). 스케줄 입력 날짜(new Date("YYYY-MM-DD"))와 동일 기준. +function todayUTC(): Date { + const n = new Date(); + return new Date(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate())); +} + +// earliest_due_date(문자열/null) → 유효한 Date 또는 null +function parseDueDate(raw: unknown): Date | null { + if (raw == null) return null; + const s = String(raw).trim(); + if (!s) return null; + // "2026-05-21" 또는 "2026-05-21T..." 형태 모두 앞 10자리만 사용 → UTC 자정 파싱 + const d = new Date(s.slice(0, 10)); + return isNaN(d.getTime()) ? null : d; +} + +/** + * 단일 품목 스케줄 날짜 산출 (preview/generate 공통). + * - earliest_due_date 가 유효하지 않으면 오늘을 납기로 간주. + * - 생산일수: lead_time>0 이면 lead_time, 아니면 ceil(qty/dailyCapacity) (최소 1일). + * - 시작일이 과거면 오늘부터 재배치(근무일 수 유지) — 현행 정책 유지(납기 초과 시 경고만). + */ +function computeScheduleDates( + requiredQty: number, + earliestDueDate: unknown, + itemLeadTime: number, + dailyCapacity: number, + safetyLeadTime: number +): { startDate: Date; endDate: Date; productionDays: number; dueExceeded: boolean } { + const productionDays = + itemLeadTime > 0 ? itemLeadTime : Math.max(1, Math.ceil(requiredQty / dailyCapacity)); + + // 납기일 미설정 시 오늘을 납기로 간주 (스케줄은 산출하되 dueExceeded 로 경고) + const parsedDue = parseDueDate(earliestDueDate); + const dueDate = parsedDue || todayUTC(); + + let endDate: Date; + let startDate: Date; + + if (itemLeadTime > 0) { + // 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전 + endDate = skipWeekend(new Date(dueDate), "backward"); + startDate = addWorkingDays(endDate, -(productionDays - 1)); + } else { + // 리드타임 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일) + endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime); + startDate = addWorkingDays(endDate, -(productionDays - 1)); + } + + // 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지 + const today = todayUTC(); + if (startDate < today) { + startDate = skipWeekend(today, "forward"); + endDate = addWorkingDays(startDate, productionDays - 1); + } + + // 납기 초과 판정: 종료일이 납기일(주말 보정 후)보다 늦으면 경고 + const dueDeadline = skipWeekend(new Date(dueDate), "backward"); + const dueExceeded = endDate.getTime() > dueDeadline.getTime(); + + return { startDate, endDate, productionDays, dueExceeded }; +} + +function fmtDate(d: Date): string { + return d.toISOString().split("T")[0]; +} + // ─── 수주 데이터 조회 (품목별 그룹핑) ─── export async function getOrderSummary( @@ -570,11 +643,17 @@ export async function previewSchedule( ) { const pool = getPool(); const productType = options.product_type || "완제품"; - const safetyLeadTime = options.safety_lead_time || 1; + // safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1 + const safetyLeadTime = + options.safety_lead_time != null ? options.safety_lead_time : 1; const previews: any[] = []; const deletedSchedules: any[] = []; const keptSchedules: any[] = []; + // 스케줄 대상에서 제외된 품목 (수량 0 / 납기 미설정 등) — 화면 안내용 + const skipped: any[] = []; + // 납기 초과 경고가 붙은 품목 + const warnings: any[] = []; // 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행 if (options.recalculate_unstarted) { @@ -610,31 +689,25 @@ export async function previewSchedule( // (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨) const requiredQty = item.required_qty; - if (requiredQty <= 0) continue; - - // 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일) - const dueDate = new Date(item.earliest_due_date); - const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity); - let endDate: Date; - let startDate: Date; - - if (itemLeadTime > 0) { - // 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전 - endDate = skipWeekend(new Date(dueDate), "backward"); - startDate = addWorkingDays(endDate, -(productionDays - 1)); - } else { - // 리드타임이 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일) - endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime); - startDate = addWorkingDays(endDate, -(productionDays - 1)); + // 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등) + if (!(requiredQty > 0)) { + skipped.push({ + item_code: item.item_code, + item_name: item.item_name, + reason: "계획 수량이 0 이하", + }); + continue; } - const today = new Date(); - today.setHours(0, 0, 0, 0); - if (startDate < today) { - // 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지 - startDate = skipWeekend(today, "forward"); - endDate = addWorkingDays(startDate, productionDays - 1); - } + const hasValidDue = parseDueDate(item.earliest_due_date) != null; + + const { startDate, endDate, productionDays, dueExceeded } = computeScheduleDates( + requiredQty, + item.earliest_due_date, + itemLeadTime, + dailyCapacity, + safetyLeadTime + ); // 해당 품목의 수주 건수 확인 const orderCountResult = await pool.query( @@ -644,18 +717,34 @@ export async function previewSchedule( ); const orderCount = parseInt(orderCountResult.rows[0].cnt, 10); + // 경고 사유 수집 (스케줄은 산출 — 현행 정책: 경고만) + const itemWarnings: string[] = []; + if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)"); + if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과"); + + if (itemWarnings.length > 0) { + warnings.push({ + item_code: item.item_code, + item_name: item.item_name, + messages: itemWarnings, + }); + } + previews.push({ item_code: item.item_code, item_name: item.item_name, required_qty: requiredQty, + plan_qty: requiredQty, daily_capacity: dailyCapacity, hourly_capacity: item.hourly_capacity || 100, production_days: productionDays, - start_date: startDate.toISOString().split("T")[0], - end_date: endDate.toISOString().split("T")[0], - due_date: item.earliest_due_date, + start_date: fmtDate(startDate), + end_date: fmtDate(endDate), + due_date: hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null, lead_time: itemLeadTime, order_count: orderCount, + due_exceeded: dueExceeded, + warning: itemWarnings.length > 0 ? itemWarnings.join(", ") : null, status: "planned", }); } @@ -665,10 +754,12 @@ export async function previewSchedule( new_count: previews.length, kept_count: keptSchedules.length, deleted_count: deletedSchedules.length, + skipped_count: skipped.length, + warning_count: warnings.length, }; logger.info("자동 스케줄 미리보기", { companyCode, summary }); - return { summary, schedules: previews, deletedSchedules, keptSchedules }; + return { summary, schedules: previews, deletedSchedules, keptSchedules, skipped, warnings }; } export async function generateSchedule( @@ -680,7 +771,9 @@ export async function generateSchedule( const pool = getPool(); const client = await pool.connect(); const productType = options.product_type || "완제품"; - const safetyLeadTime = options.safety_lead_time || 1; + // safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1 + const safetyLeadTime = + options.safety_lead_time != null ? options.safety_lead_time : 1; try { await client.query("BEGIN"); @@ -688,6 +781,8 @@ export async function generateSchedule( let deletedCount = 0; let keptCount = 0; const newSchedules: any[] = []; + const skipped: any[] = []; + const warnings: any[] = []; const deletedQtyByItem = new Map(); // 같은 item_code에 대한 삭제는 한 번만 수행 @@ -734,28 +829,37 @@ export async function generateSchedule( // 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용 // (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨) const requiredQty = item.required_qty; - if (requiredQty <= 0) continue; - // 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일) - const dueDate = new Date(item.earliest_due_date); - const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity); - let endDate: Date; - let startDate: Date; - - if (itemLeadTime > 0) { - endDate = skipWeekend(new Date(dueDate), "backward"); - startDate = addWorkingDays(endDate, -(productionDays - 1)); - } else { - endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime); - startDate = addWorkingDays(endDate, -(productionDays - 1)); + // 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등) + if (!(requiredQty > 0)) { + skipped.push({ + item_code: item.item_code, + item_name: item.item_name, + reason: "계획 수량이 0 이하", + }); + continue; } - // 시작일이 오늘보다 이전이면 오늘(주말이면 다음 평일)로 조정 - const today = new Date(); - today.setHours(0, 0, 0, 0); - if (startDate < today) { - startDate = skipWeekend(today, "forward"); - endDate = addWorkingDays(startDate, productionDays - 1); + const hasValidDue = parseDueDate(item.earliest_due_date) != null; + + // 리드타임 기반 날짜 계산 (preview 와 동일 로직 — computeScheduleDates 공유) + const { startDate, endDate, dueExceeded } = computeScheduleDates( + requiredQty, + item.earliest_due_date, + itemLeadTime, + dailyCapacity, + safetyLeadTime + ); + + const itemWarnings: string[] = []; + if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)"); + if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과"); + if (itemWarnings.length > 0) { + warnings.push({ + item_code: item.item_code, + item_name: item.item_name, + messages: itemWarnings, + }); } // 계획번호 생성 (YYYYMMDD-NNNN 형식) @@ -784,16 +888,17 @@ export async function generateSchedule( [ companyCode, planNo, item.item_code, item.item_name, productType, requiredQty, - startDate.toISOString().split("T")[0], - endDate.toISOString().split("T")[0], - item.earliest_due_date, + fmtDate(startDate), + fmtDate(endDate), + // 납기 미설정/빈값이면 NULL 저장 ('' 를 date 컬럼에 넣으면 에러) + hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null, item.hourly_capacity || 100, dailyCapacity, item.lead_time || 1, createdBy, ] ); - newSchedules.push(insertResult.rows[0]); + newSchedules.push({ ...insertResult.rows[0], due_exceeded: dueExceeded }); } await client.query("COMMIT"); @@ -803,10 +908,12 @@ export async function generateSchedule( new_count: newSchedules.length, kept_count: keptCount, deleted_count: deletedCount, + skipped_count: skipped.length, + warning_count: warnings.length, }; logger.info("자동 스케줄 생성 완료", { companyCode, summary }); - return { summary, schedules: newSchedules }; + return { summary, schedules: newSchedules, skipped, warnings }; } catch (error) { await client.query("ROLLBACK"); logger.error("자동 스케줄 생성 실패", { companyCode, error }); diff --git a/backend-node/src/services/salesOrderBulkService.ts b/backend-node/src/services/salesOrderBulkService.ts index f64b5abf..89643d29 100644 --- a/backend-node/src/services/salesOrderBulkService.ts +++ b/backend-node/src/services/salesOrderBulkService.ts @@ -67,6 +67,28 @@ function normStr(v: any): string { return String(v).trim(); } +/** + * category_values에서 라벨로 value_code resolve (회사별) + * - 코드값은 회사/환경마다 다르므로 라벨 기준으로 조회 + * - 매칭 실패 시 null 반환 (호출부에서 빈값 처리) + */ +async function resolveCategoryCode( + client: any, + companyCode: string, + tableName: string, + columnName: string, + label: string +): Promise { + const r = await client.query( + `SELECT value_code FROM category_values + WHERE company_code = $1 AND table_name = $2 AND column_name = $3 + AND TRIM(value_label) = $4 + LIMIT 1`, + [companyCode, tableName, columnName, label] + ); + return r.rows.length > 0 ? r.rows[0].value_code : null; +} + /** * 엑셀 한 줄 → item_info에서 기존 품목 조회, 없으면 INSERT * 반환: { itemNumber: string, created: boolean } @@ -125,18 +147,29 @@ async function resolveOrCreateItem( const seq = (Number(seqR.rows[0]?.c) || 0) + 1; const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`; - const division = normStr(row.division) || defaultDivision; const unit = normStr(row.unit) || "EA"; + // 항목 9: 관리품목="영업관리", 품목구분="제품", 상태="활성" 고정값 + // category_values에서 COMPANY_9 기준 라벨→코드 resolve (코드 직접 하드코딩 금지) + const divisionCode = + (await resolveCategoryCode(client, companyCode, "item_info", "division", "영업관리")) || ""; + const typeCode = + (await resolveCategoryCode(client, companyCode, "item_info", "type", "제품")) || ""; + const statusCode = + (await resolveCategoryCode(client, companyCode, "item_info", "status", "활성")) || ""; + + // 항목 12: 수주 디테일 단가 → 판매단가(selling_price). 기준단가(standard_price) 아님 + const sellingPrice = toNum(row.unit_price); + await client.query( `INSERT INTO item_info ( id, company_code, item_number, item_name, size, width, height, thickness, - unit, division, status, writer, created_date + unit, division, type, status, selling_price, writer, created_date ) VALUES ( gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, - $8, $9, 'active', $10, NOW() + $8, $9, $10, $11, $12, $13, NOW() )`, [ companyCode, @@ -147,7 +180,10 @@ async function resolveOrCreateItem( toNum(row.height) || null, toNum(row.thickness) || null, unit, - division, + divisionCode, + typeCode, + statusCode, + sellingPrice ? String(sellingPrice) : null, userId, ] ); diff --git a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx index 258eb2dc..73320147 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx @@ -104,6 +104,7 @@ const GRID_COLUMNS = [ { key: "reference_number", label: "참조번호" }, { key: "source_table", label: "데이터출처" }, { key: "customer_name", label: "거래처" }, + { key: "delivery_destination_name", label: "납품처" }, { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품목명" }, { key: "width", label: "가로" }, @@ -118,8 +119,8 @@ const GRID_COLUMNS = [ { key: "remark", label: "비고" }, ]; -// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(18) = 19 -const TOTAL_COLS = 19; +// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(19) = 20 +const TOTAL_COLS = 20; // 헤더 필터 Popover function HeaderFilterPopover({ @@ -1021,6 +1022,7 @@ export default function OutboundPage() { {row.reference_number || ""} {row.source_table || ""} {row.customer_name || ""} + {(row as any).delivery_destination_name || (row as any).delivery_destination || "-"} {row.item_number || ""} {row.item_name || ""} {(row as any).width || "-"} diff --git a/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx index 66b467bb..d8f566e9 100644 --- a/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx @@ -450,7 +450,9 @@ export default function PackagingPage() { }; const savePkgMatch = async () => { - if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; } + if (!selectedLoading) { toast.error("적재함을 먼저 등록해주세요."); return; } + if (!selectedLoading.id) { toast.error("적재함을 먼저 등록해주세요."); return; } + if (!pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; } if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; } setSaving(true); try { @@ -461,6 +463,7 @@ export default function PackagingPage() { load_method: pkgMatchMethod || undefined, }); if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); } + else { toast.error(res.message || "추가 실패"); } } catch { toast.error("추가 실패"); } finally { setSaving(false); } }; @@ -1057,13 +1060,20 @@ export default function PackagingPage() { {(() => { const kw = pkgMatchSearchKw.toLowerCase(); + // 등록된 포장재(pkg_unit에 id 보유)만 후보로. 미등록 placeholder 품목 제외. + // 상태(ACTIVE/INACTIVE) 무관 — INACTIVE도 선택 가능하도록 노출. const filtered = pkgUnits.filter(p => - p.status === "ACTIVE" + !!p.id && !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code) && (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw)) ); + const hasRegisteredPkg = pkgUnits.some(p => !!p.id); return filtered.length === 0 ? ( - 추가 가능한 포장단위가 없습니다 + + {hasRegisteredPkg + ? "추가 가능한 포장단위가 없습니다" + : "등록된 포장재가 없습니다. 포장재 관리 탭에서 포장재를 먼저 등록해주세요."} + ) : filtered.map((p) => ( {pkgMatchSelected?.id === p.id ? "✓" : ""} {p.pkg_code} - {p.pkg_name} + + {p.pkg_name} + {p.status !== "ACTIVE" && ( + + {STATUS_LABEL[p.status] || "미사용"} + + )} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type} {fmtSize(p.width_mm, p.length_mm, p.height_mm)} {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"} diff --git a/frontend/app/(main)/COMPANY_9/pop/_components/outbound/SalesOutbound.tsx b/frontend/app/(main)/COMPANY_9/pop/_components/outbound/SalesOutbound.tsx index 45f8d090..5ed303fe 100644 --- a/frontend/app/(main)/COMPANY_9/pop/_components/outbound/SalesOutbound.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/_components/outbound/SalesOutbound.tsx @@ -108,7 +108,8 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT `/table-management/tables/${CUSTOMER_SOURCE.tableName}/data`, { page: 1, - size: 500, + // 거래처는 마스터 참조 데이터 — size:0 으로 전체 조회 (임의 리밋 금지) + size: 0, autoFilter: true, sort: { columnName: CUSTOMER_SOURCE.fields.code, order: "desc" }, }, @@ -237,13 +238,11 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT fetchOrders(); }, [fetchOrders]); - /* Filter orders by selected customer */ - const filteredOrders = selectedCustomer - ? orders.filter((o) => - o.customer_code === selectedCustomer.customer_code || - o.customer_name === selectedCustomer.customer_name - ) - : orders; + /* 출하지시 목록 — 거래처 필터는 백엔드(getShipmentInstructions)에서 이미 + * 거래처코드/거래처명 양쪽으로 매칭해 반환하므로, 프론트에서 customer_code + * 일치로 재필터하면 partner_id 가 거래처명으로 저장된 건이 누락됨. 백엔드 + * 결과를 그대로 신뢰하고 키워드 필터만 클라이언트에서 적용한다. */ + const filteredOrders = orders; /* Filter by keyword */ const displayOrders = keyword diff --git a/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx index 2464bf49..80e681bf 100644 --- a/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx @@ -134,6 +134,10 @@ interface ChecklistItem { lower_limit: string | null; upper_limit: string | null; input_type: string | null; + /** 검사기준 마스터(inspection_standard)의 판단기준 코드 — CAT_JC_01 수치 / CAT_JC_03 O/X 등 */ + judgment_criteria?: string | null; + /** 선택형(CAT_JC_04) 판단기준의 선택지 목록 */ + selection_options?: string | null; group_started_at: string | null; group_paused_at: string | null; group_total_paused_time: string | null; @@ -586,12 +590,23 @@ export function ProcessWork({ } } - // 3. Fetch checklist (process_work_result) - const checkRes = await dataApi.getTableData("process_work_result", { - size: 500, - filters: { work_order_process_id: processId }, - }); - setChecklist((checkRes.data ?? []) as ChecklistItem[]); + // 3. Fetch checklist — 전용 엔드포인트 사용. + // /checklist-items 는 inspection_standard 를 JOIN 하여 판단기준(judgment_criteria) 을 + // 포함해 내려준다(O/X vs 측정값 분기 렌더링에 필요). + // generic table-management 조회는 judgment_criteria 가 빠져 모두 측정값 입력으로 잘못 표시됨. + try { + const checkRes = await apiClient.get( + `/pop/production/checklist-items/${processId}`, + ); + setChecklist((checkRes.data?.data ?? []) as ChecklistItem[]); + } catch { + // fallback: 전용 엔드포인트 실패 시 generic 조회 (판단기준 분기 없이 동작) + const fbRes = await dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: processId }, + }); + setChecklist((fbRes.data ?? []) as ChecklistItem[]); + } // 4. Defect types — 품목별 + 현재 공정 공정검사 항목 (item_inspection_info) try { @@ -2580,24 +2595,30 @@ function ChecklistRow({ // Inspection type: check limits const detailType = item.detail_type || ""; - // 판단기준(judgment_criteria) 우선 → 폴백으로 detail_type 매핑 - const jc = - (item as ChecklistItem & { judgment_criteria?: string }) - .judgment_criteria || ""; - const isInspection = - jc === "CAT_JC_01" || - detailType === "inspection" || - detailType === "number" || - detailType === "equip_condition" || - detailType === "production_result" || - detailType.startsWith("inspect"); - const isCheckbox = - jc === "CAT_JC_03" || - detailType === "checkbox" || - detailType === "check" || - detailType === "checklist" || - detailType === "procedure" || - detailType === "equip_inspection"; + // 판단기준(judgment_criteria) — 검사기준 마스터(inspection_standard)에서 JOIN. + // CAT_JC_01 수치(범위) / CAT_JC_02 텍스트입력 / CAT_JC_03 O/X / CAT_JC_04 선택형 + const jc = (item.judgment_criteria || "").trim(); + // 판단기준이 설정돼 있으면 그 값이 렌더링 방식을 '단독으로' 결정한다. + // - O/X(CAT_JC_03) 인데 detail_type='inspection' 이라 측정값 칸으로 잘못 렌더되던 결함 수정. + // - 판단기준 미설정 시에만 기존 detail_type 폴백 사용(현 동작 유지). + const hasJc = jc.startsWith("CAT_JC_"); + // O/X 판단 (합격/불합격 토글) + const isCheckbox = hasJc + ? jc === "CAT_JC_03" + : detailType === "checkbox" || + detailType === "check" || + detailType === "checklist" || + detailType === "procedure" || + detailType === "equip_inspection"; + // 측정값(수치) 입력 + const isInspection = hasJc + ? jc === "CAT_JC_01" + : detailType === "inspection" || + detailType === "number" || + detailType === "equip_condition" || + detailType === "production_result" || + detailType.startsWith("inspect"); + // 텍스트입력(CAT_JC_02) / 선택형(CAT_JC_04) 은 일반 텍스트 입력칸으로 렌더 const isPlc = item.input_type === "plc" || detailType === "plc_data"; const hasLimits = !!(item.lower_limit || item.upper_limit); diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx index b4a70af2..b578e341 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx @@ -30,7 +30,7 @@ import { cn } from "@/lib/utils"; import { previewWorkInstructionNo, saveWorkInstruction, - getEquipmentList, getEmployeeList, + getEquipmentList, getEmployeeList, getRoutingVersions, } from "@/lib/api/workInstruction"; // ─── 공용 다중선택 Popover (설비/작업조/작업자) ──────────────────── @@ -150,6 +150,8 @@ export default function WorkInstructionApplyModal({ const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]); const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]); + // 품목별 라우팅 등록 여부 (true=등록됨 / false=미등록). 로딩 중인 품목은 키 부재. + const [routingStatus, setRoutingStatus] = useState>({}); // 모달 오픈 시 초기화 + 옵션 로드 useEffect(() => { @@ -165,14 +167,35 @@ export default function WorkInstructionApplyModal({ }))); setStatus("일반"); setRemark(""); + setRoutingStatus({}); previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {}); getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {}); getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {}); + + // 품목별 라우팅 등록 여부 사전 조회 (엣지 1 — 라우팅 미등록 안내). + // 라우팅 세팅 자체는 백엔드 save 가 자동 처리하므로 여기서는 안내 표시용으로만 사용. + const uniqueCodes = [...new Set(initialItems.map((x) => x.itemCode).filter(Boolean))]; + for (const code of uniqueCodes) { + getRoutingVersions("__new__", code) + .then((r) => { + const has = !!(r.success && r.data && r.data.length > 0); + setRoutingStatus((prev) => ({ ...prev, [code]: has })); + }) + .catch(() => { + // 조회 실패 시 안내를 띄우지 않음(미등록으로 단정하지 않음) + }); + } }, [open, initialItems]); const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]); + // 라우팅 미등록으로 확인된 품목 코드 목록 + const noRoutingCodes = useMemo( + () => [...new Set(items.map((i) => i.itemCode).filter((c) => c && routingStatus[c] === false))], + [items, routingStatus] + ); + const handleSave = async () => { if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; } setSaving(true); @@ -237,6 +260,13 @@ export default function WorkInstructionApplyModal({
+ {noRoutingCodes.length > 0 && ( +
+ 라우팅 미등록 안내 — 다음 품목은 공정 라우팅이 등록되어 있지 않아 + 작업지시에 라우팅이 연결되지 않습니다(생성은 가능). 품목정보에서 라우팅 버전을 등록해주세요: + {noRoutingCodes.join(", ")} +
+ )}

작업지시 기본 정보

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx index 5b9b3873..5d84fefc 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx @@ -169,7 +169,7 @@ export default function CuttingPlanPage() { try { // COMPANY_9는 마스터-디테일 구조 — sales_order_detail에서 직접 조회. // 공통 /cutting-plan/orders는 sales_order_mng.part_name 필터 때문에 데이터가 누락됨. - const [detailRes, masterRes, planItemRes, planMngRes] = await Promise.all([ + const [detailRes, masterRes, planItemRes, planMngRes, customerRes] = await Promise.all([ apiClient.post(`/table-management/tables/sales_order_detail/data`, { page: 1, size: 0, autoFilter: true, }), @@ -182,13 +182,23 @@ export default function CuttingPlanPage() { apiClient.post(`/table-management/tables/cutting_plan_mng/data`, { page: 1, size: 0, autoFilter: true, }).catch(() => ({ data: { data: { data: [] } } })), + // 거래처 코드(CUST-001)를 거래처명으로 표시하기 위한 마스터 참조 (size:0 전체 조회) + apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 0, autoFilter: true, + }).catch(() => ({ data: { data: { data: [] } } })), ]); const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; const planItems = planItemRes.data?.data?.data || planItemRes.data?.data?.rows || []; const planMngs = planMngRes.data?.data?.data || planMngRes.data?.data?.rows || []; + const customers = customerRes.data?.data?.data || customerRes.data?.data?.rows || []; const masterMap = new Map(masters.map((m: any) => [m.order_no, m])); + // 거래처 코드 → 거래처명 매핑 (매핑 깨짐/빈값이면 코드 fallback) + const customerNameMap = new Map(); + for (const c of customers) { + if (c.customer_code) customerNameMap.set(String(c.customer_code), c.customer_name || ""); + } const planMngMap = new Map(planMngs.map((p: any) => [String(p.id), p])); // src_no(=order_no) → 첫 plan 매칭 const orderToPlan = new Map(); @@ -232,9 +242,14 @@ export default function CuttingPlanPage() { const qty = parseFloat(d.qty || "0") || 0; const balance = parseFloat(d.balance_qty || "0") || qty; const batch = orderToPlan.get(d.order_no); + // 거래처: partner_id(코드) → customer_mng 거래처명. 매핑 깨짐/빈값이면 코드 fallback. + const partnerCode = m.partner_id || ""; + const customerName = partnerCode + ? (customerNameMap.get(String(partnerCode)) || partnerCode) + : "-"; return { order_no: d.order_no, - customer: m.partner_id || "-", + customer: customerName, partner_id: m.partner_id, part_code: d.part_code || "", part_name: d.part_name || "-", diff --git a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx index cd159a0b..8591d2f4 100644 --- a/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx @@ -621,6 +621,14 @@ export default function ProductionPlanManagementPage() { }); if (res.success) { toast.success(`스케줄이 생성되었습니다 (${res.data.summary.total}건)`); + const warnCnt = res.data.summary?.warning_count || 0; + const skipCnt = res.data.summary?.skipped_count || 0; + if (warnCnt > 0) { + toast.warning(`납기 경고 ${warnCnt}건 — 종료일이 납기를 초과하거나 납기일 미설정 품목이 있습니다.`); + } + if (skipCnt > 0) { + toast.info(`${skipCnt}건은 수량이 없어 제외되었습니다.`); + } setChangeConfirmModalOpen(false); setPreviewData(null); fetchPlans(); @@ -1993,6 +2001,49 @@ export default function ProductionPlanManagementPage() {
+ {/* 납기일 미설정/초과 경고 (스케줄은 산출됨) */} + {((previewData as any).warnings?.length || 0) > 0 && ( +
+

+ + 납기 경고 ({(previewData as any).warnings.length}건) +

+
    + {((previewData as any).warnings || []).map((w: any, i: number) => ( +
  • + {w.item_name || w.item_code} + {" — "} + {(w.messages || []).join(", ")} +
  • + ))} +
+
+ )} + + {/* 스케줄 대상에서 제외된 품목 (수량 0 등) */} + {((previewData as any).skipped?.length || 0) > 0 && ( +
+

+ 제외됨 ({(previewData as any).skipped.length}건) +

+
    + {((previewData as any).skipped || []).map((s: any, i: number) => ( +
  • + {s.item_name || s.item_code} + {" — "} + {s.reason} +
  • + ))} +
+
+ )} + + {(previewData.schedules?.length || 0) === 0 && ( +
+ 생성할 스케줄이 없습니다. 수주 잔량/납기일을 확인해주세요. +
+ )} + {(previewData.schedules?.length || 0) > 0 && (

diff --git a/frontend/app/(main)/COMPANY_9/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_9/purchase/supplier/page.tsx index 9b516330..4dc84da1 100644 --- a/frontend/app/(main)/COMPANY_9/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_9/purchase/supplier/page.tsx @@ -714,7 +714,7 @@ 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 errors = validateForm(supplierForm, ["contact_phone", "email", "business_number", "fax_number"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); @@ -1896,6 +1896,16 @@ export default function SupplierManagementPage() { /> {formErrors.business_number &&

{formErrors.business_number}

}
+
+ + handleFormChange("fax_number", e.target.value)} + placeholder="02-1234-5678" + className={cn("h-9", formErrors.fax_number && "border-destructive")} + /> + {formErrors.fax_number &&

{formErrors.fax_number}

} +
{(() => { const code = row.apply_process; if (!code) return "-"; - // excelItemProcessMappings에서 공정명 찾기 + // 1) 백엔드 process_mng JOIN 결과(apply_process_name) 우선 사용 + if (row.apply_process_name) return row.apply_process_name; + // 2) excelItemProcessMappings에서 공정명 찾기 for (const m of excelItemProcessMappings) { const proc = m.processes.find(p => p.code === code); if (proc) return proc.name; } - // processOptions (모달용)에서 찾기 + // 3) processOptions (모달용)에서 찾기 const proc = processOptions.find(p => p.code === code); + // 매핑이 깨졌거나 못 찾으면 코드 fallback return proc?.name || code; })()} {row.classification || "-"} diff --git a/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx index 2293bd91..af40b277 100644 --- a/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx @@ -715,7 +715,7 @@ export default function CustomerManagementPage() { const handleCustomerSave = async () => { if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; } if (!customerForm.status) { toast.error("상태는 필수입니다."); return; } - const errors = validateForm(customerForm, ["business_number"]); + const errors = validateForm(customerForm, ["business_number", "phone", "fax"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); @@ -1887,6 +1887,26 @@ export default function CustomerManagementPage() { /> {formErrors.business_number &&

{formErrors.business_number}

}
+
+ + handleFormChange("phone", e.target.value)} + placeholder="02-1234-5678" + className={cn("h-9", formErrors.phone && "border-destructive")} + /> + {formErrors.phone &&

{formErrors.phone}

} +
+
+ + handleFormChange("fax", e.target.value)} + placeholder="02-1234-5678" + className={cn("h-9", formErrors.fax && "border-destructive")} + /> + {formErrors.fax &&

{formErrors.fax}

} +
(); const [categoryOptions, setCategoryOptions] = useState>({}); + // 납품처 (거래처별 delivery_destination) — TASK:ERP-097 + // 거래처 선택 시 해당 거래처에 등록된 납품처 목록을 조회한다. + const [destinations, setDestinations] = useState([]); + const [destLoading, setDestLoading] = useState(false); + // 선택한 납품처 id (수주 저장에는 destination_name을 텍스트로 저장 → 하류 화면 호환) + const [selectedDestId, setSelectedDestId] = useState(""); + // 채번 const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -203,8 +210,8 @@ export default function JeilGlassOrderPage() { label: `${u.user_name || ""}${u.position_name ? ` (${u.position_name})` : ""}`, })); } catch { /* skip */ } - // 품목 카테고리 (단위, 구분, 재질, 유형) - for (const col of ["unit", "division", "material", "type"]) { + // 품목 카테고리 (단위, 구분, 재질, 유형, 상태) + for (const col of ["unit", "division", "material", "type", "status"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success && res.data.data?.length > 0) { @@ -322,6 +329,49 @@ export default function JeilGlassOrderPage() { useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]); + // 거래처별 납품처 목록 조회 — TASK:ERP-097 + // 거래처(partner_id)가 바뀌면 해당 거래처의 delivery_destination 레코드를 전체 조회. + const loadDestinations = useCallback(async (customerCode: string) => { + if (!customerCode) { + setDestinations([]); + return; + } + setDestLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setDestinations(rows); + } catch { + setDestinations([]); + } finally { + setDestLoading(false); + } + }, []); + + // 모달의 거래처(partner_id)가 바뀌면 납품처 목록 재조회 + useEffect(() => { + if (!isModalOpen) return; + loadDestinations(masterForm.partner_id || ""); + }, [masterForm.partner_id, isModalOpen, loadDestinations]); + + // 납품처 목록 로드 후, 저장된 납품장소(delivery_address) 값과 매칭되는 납품처 id 복원 (수정 진입 시) + useEffect(() => { + if (!isModalOpen) return; + const addr = masterForm.delivery_address || ""; + if (!addr || destinations.length === 0) { + setSelectedDestId(""); + return; + } + const matched = destinations.find( + (d) => d.destination_name === addr || d.address === addr, + ); + setSelectedDestId(matched ? String(matched.id) : ""); + }, [destinations, masterForm.delivery_address, isModalOpen]); + // 서버 페이징 계산 const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); const safePage = Math.min(Math.max(1, currentPage), totalPages); @@ -381,6 +431,8 @@ export default function JeilGlassOrderPage() { } setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" }); setModalDetailRows([]); + setSelectedDestId(""); + setDestinations([]); setIsEditMode(false); setIsModalOpen(true); }; @@ -500,9 +552,16 @@ export default function JeilGlassOrderPage() { } // 없으면 자동 등록 // COMPANY_9 한정: 수주 디테일 입력값을 신규 item_info 레코드에 연동 - // (width/height/thickness/size/unit/standard_price 6개 컬럼) - // selling_price(판매가격)는 절대 연동 금지 — 기존 품목은 이미 위 found 분기에서 보호됨 + // (width/height/thickness/size/unit + 판매단가(selling_price)) + // 신규 생성 시에만 적용 — 기존 품목은 위 found 분기에서 보호됨 // company_code를 명시해야 백엔드가 회사별 채번 규칙으로 item_number를 자동 발급함 + // 관리품목/품목구분/상태: category_values에서 라벨로 코드 resolve (코드 직접 하드코딩 금지) + const divisionCode = + categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code || ""; + const typeCode = + categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code || ""; + const statusCode = + categoryOptions["item_status"]?.find((o) => o.label === "활성")?.code || ""; await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), company_code: user?.companyCode || user?.company_code, @@ -512,7 +571,12 @@ export default function JeilGlassOrderPage() { width: row.width || "", height: row.height || "", thickness: row.thickness || "", - standard_price: row.unit_price || "", + // 항목 12: 수주 디테일 단가 → 판매단가(selling_price). 기준단가(standard_price) 아님 + selling_price: row.unit_price || "", + // 항목 9: 관리품목="영업관리", 품목구분="제품", 상태="활성" 고정값 + division: divisionCode, + type: typeCode, + status: statusCode, }); // 등록 후 재조회하여 item_number 획득 const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { @@ -972,8 +1036,15 @@ export default function JeilGlassOrderPage() { )}
-
-
+
+ {/* 납품처: 거래처별 delivery_destination 선택 — TASK:ERP-097 */} +
+
+ + {selectedDestId && ( + + )} +
+ { + setSelectedDestId(v); + const dest = destinations.find((d) => String(d.id) === v); + if (dest) { + // 선택한 납품처명을 납품장소 텍스트로 저장 (하류 화면 호환) + const label = dest.destination_name || dest.address || ""; + setMasterForm((p) => ({ ...p, delivery_address: label })); + // 이미 추가된 품목행의 납품장소도 일괄 갱신 + setModalDetailRows((prev) => + prev.map((r) => ({ ...r, delivery_location: label })), + ); + } + }} + options={destinations.map((d) => ({ + code: String(d.id), + label: `${d.destination_name || d.address || d.destination_code}${d.address && d.destination_name ? ` (${d.address})` : ""}`, + }))} + placeholder={ + !masterForm.partner_id + ? "거래처를 먼저 선택해주세요" + : destLoading + ? "납품처 불러오는 중..." + : destinations.length === 0 + ? "등록된 납품처 없음" + : "납품처 선택" + } + disabled={!masterForm.partner_id || destLoading} + /> +
+ {/* 납품장소: 자유 입력 (납품처 미등록 거래처용 fallback) */}
setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))} diff --git a/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx index a8c95d2e..f101d168 100644 --- a/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx @@ -31,6 +31,7 @@ const GRID_COLUMNS = [ { key: "instruction_no", label: "출하지시번호" }, { key: "ship_date", label: "출하일자" }, { key: "customer_name", label: "거래처명" }, + { key: "delivery_destination_name", label: "납품처" }, { key: "transport_company", label: "운송업체" }, { key: "vehicle_no", label: "차량번호" }, { key: "driver_name", label: "기사명" }, @@ -414,6 +415,7 @@ export default function ShippingOrderPage() { instruction_no: order.instruction_no, ship_date: formatDate(order.instruction_date), customer_name: order.customer_name || "-", + delivery_destination_name: order.delivery_destination_name || "-", transport_company: order.carrier_name || "-", vehicle_no: order.vehicle_no || "-", driver_name: order.driver_name || "-", @@ -433,6 +435,7 @@ export default function ShippingOrderPage() { instruction_no: idx === 0 ? order.instruction_no : "", ship_date: idx === 0 ? formatDate(order.instruction_date) : "", customer_name: idx === 0 ? (order.customer_name || "-") : "", + delivery_destination_name: idx === 0 ? (order.delivery_destination_name || "-") : "", transport_company: idx === 0 ? (order.carrier_name || "-") : "", vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "", driver_name: idx === 0 ? (order.driver_name || "-") : "", diff --git a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx index 29a84962..4bb8a12a 100644 --- a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx @@ -25,6 +25,7 @@ const GRID_COLUMNS = [ { key: "order_no", label: "수주번호" }, { key: "due_date", label: "납기일" }, { key: "customer_name", label: "거래처" }, + { key: "delivery_destination_name", label: "납품처" }, { key: "part_code", label: "품목코드" }, { key: "part_name", label: "품목명" }, { key: "order_qty", label: "수주수량" }, @@ -252,6 +253,7 @@ export default function ShippingPlanPage() { { key: "order_no", label: "수주번호", render: (val: any) => {val || "-"} }, { key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => {formatDate(val)} }, { key: "customer_name", label: "거래처", render: (val: any) => {val || "-"} }, + { key: "delivery_destination_name", label: "납품처", render: (val: any) => {val || "-"} }, { key: "part_code", label: "품목코드", render: (val: any) => {val || "-"} }, { key: "part_name", label: "품목명", render: (val: any) => {val || "-"} }, { key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true }, diff --git a/frontend/lib/api/itemInspection.ts b/frontend/lib/api/itemInspection.ts index cc361621..a9669368 100644 --- a/frontend/lib/api/itemInspection.ts +++ b/frontend/lib/api/itemInspection.ts @@ -15,6 +15,7 @@ export interface InspectionRowApi { inspection_item_name: string | null; inspection_method: string | null; apply_process: string | null; + apply_process_name?: string | null; classification: string | null; pass_criteria: string | null; upper_limit?: string | number | null; diff --git a/frontend/lib/api/outbound.ts b/frontend/lib/api/outbound.ts index 65804ae3..8be4f50b 100644 --- a/frontend/lib/api/outbound.ts +++ b/frontend/lib/api/outbound.ts @@ -31,6 +31,12 @@ export interface OutboundItem { destination_code: string | null; delivery_destination: string | null; delivery_address: string | null; + // 납품처명 (TASK:ERP-097): 목록 조회 시 출고/출하지시 체인에서 해석된 납품처명 + delivery_destination_name?: string | null; + // 품목 규격 (TASK:ERP-098): 유리업종 가로/세로/두께 (item_info JOIN, 미입력 시 빈문자열) + width?: string | null; + height?: string | null; + thickness?: string | null; created_date: string; created_by: string | null; } diff --git a/frontend/lib/api/production.ts b/frontend/lib/api/production.ts index 4b6caad1..7491373e 100644 --- a/frontend/lib/api/production.ts +++ b/frontend/lib/api/production.ts @@ -93,10 +93,16 @@ export interface GenerateScheduleResponse { new_count: number; kept_count: number; deleted_count: number; + skipped_count?: number; + warning_count?: number; }; schedules: ProductionPlan[]; deletedSchedules?: ProductionPlan[]; keptSchedules?: ProductionPlan[]; + /** 스케줄 대상에서 제외된 품목 (수량 0 등) */ + skipped?: { item_code: string; item_name: string; reason: string }[]; + /** 납기일 미설정/초과 등 경고가 붙은 품목 */ + warnings?: { item_code: string; item_name: string; messages: string[] }[]; } // ─── API 함수 ─── diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts index 2308c92b..f2cfb172 100644 --- a/frontend/lib/api/shipping.ts +++ b/frontend/lib/api/shipping.ts @@ -79,6 +79,7 @@ export interface ShipmentPlanListItem { due_date: string; order_qty: string; shipped_qty: string; + delivery_destination_name?: string; } export interface ShipmentPlanListParams { diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index 0ea6b49e..525697d8 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -145,6 +145,8 @@ export function DetailFormModal({ const [equipInspLoading, setEquipInspLoading] = useState(false); // 설비점검 row별 선택 — TASK:ERP-021 const [equipInspChecked, setEquipInspChecked] = useState>(new Set()); + // 공정에 지정된 설비 건수 — 0건(설비 미지정) vs 점검항목 0건 메시지 구분용 — TASK:ERP-102 + const [equipProcEquipCount, setEquipProcEquipCount] = useState(0); // 공정 설비 목록 (자재투입 자재별 설비 연결용) — TASK:ERP-022 const [processEquipments, setProcessEquipments] = useState([]); @@ -355,42 +357,80 @@ export function DetailFormModal({ })(); }, [open, mode, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); - // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 — TASK:ERP-102 + // 데이터 흐름: 공정코드 → process_equipment(공정-설비 매핑) → equipment_inspection_item(설비별 점검항목) useEffect(() => { if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { setEquipInspItems([]); setEquipInspChecked(new Set()); + setEquipProcEquipCount(0); return; } (async () => { setEquipInspLoading(true); try { - // 1. 해당 공정의 설비 목록 조회 - const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, - autoFilter: true, - }); - const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + // 1. 해당 공정의 설비 목록 + 설비명 매핑 조회 (참조 데이터 → size:0 전체 반환) + const [equipRes, eqMngRes] = await Promise.all([ + apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }), + apiClient.post("/table-management/tables/equipment_mng/data", { + page: 1, size: 0, autoFilter: true, + }), + ]); + const equipCodes: string[] = Array.from( + new Set( + (equipRes.data?.data?.data || equipRes.data?.data?.rows || []) + .map((e: any) => e.equipment_code) + .filter(Boolean) + ) + ); + // 설비코드 → 설비명 매핑 (점검항목에 설비명 병기용) + const eqNameMap: Record = {}; + for (const eq of (eqMngRes.data?.data?.data || eqMngRes.data?.data?.rows || [])) { + if (eq.equipment_code) eqNameMap[eq.equipment_code] = eq.equipment_name || eq.equipment_code; + } + + setEquipProcEquipCount(equipCodes.length); if (equipCodes.length === 0) { + // 공정에 지정된 설비 자체가 없음 → 메시지 분기로 원인 구분 setEquipInspItems([]); setEquipInspChecked(new Set()); return; } - // 2. 각 설비의 점검항목 조회 + // 2. 각 설비의 점검항목 조회 (참조 데이터 → size:0 전체 반환) const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, autoFilter: true, }); - const rows = inspRes.data?.data?.data || inspRes.data?.data?.rows || []; + const rawRows = inspRes.data?.data?.data || inspRes.data?.data?.rows || []; + // 설비명 주입 + 동일 점검항목(설비/항목/방법/규격 동일) 중복 제거 + const seen = new Set(); + const rows: any[] = []; + for (const r of rawRows) { + const dedupKey = [ + r.equipment_code || "", + r.inspection_item || "", + r.inspection_method || "", + r.lower_limit ?? "", + r.upper_limit ?? "", + r.unit ?? "", + ].join("|"); + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + rows.push({ ...r, equipment_name: eqNameMap[r.equipment_code] || r.equipment_code || "" }); + } setEquipInspItems(rows); // 기본값: 전체 선택 setEquipInspChecked(new Set(rows.map((r: any, idx: number) => String(r.id ?? idx)))); } catch { setEquipInspItems([]); setEquipInspChecked(new Set()); + setEquipProcEquipCount(0); } finally { setEquipInspLoading(false); } })(); }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); @@ -560,12 +600,14 @@ export function DetailFormModal({ } for (const item of checkedItems) { const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + // 설비명 병기 — 동일 점검항목이 복수 설비에 있을 때 구분 가능하도록 — TASK:ERP-102 + const equipLabel = item.equipment_name || item.equipment_code || ""; onSubmit({ ...submitData, detail_type: "equip_inspection", // 자동연동 분기는 equip_inspection_apply="apply" 가정 — 명시 셋팅으로 수정 모달 라디오 복원 보장 equip_inspection_apply: "apply", - content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + content: `${equipLabel ? `[${equipLabel}] ` : ""}${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), is_required: submitData.is_required || "Y", // 점검항목 메타 함께 전송 → 수정 모달 재진입 시 표시 복원 unit: item.unit ?? "", @@ -1162,8 +1204,16 @@ export function DetailFormModal({
{equipInspLoading ? (
조회 중...
+ ) : equipProcEquipCount === 0 ? ( + // 공정-설비 매핑 자체가 0건 — 원인 구분 메시지 — TASK:ERP-102 +

+ 이 공정에 지정된 설비가 없습니다. 공정 마스터에서 사용설비를 먼저 등록해주세요. +

) : equipInspItems.length === 0 ? ( -

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ // 설비는 지정됐으나 그 설비들에 점검항목이 0건 — TASK:ERP-102 +

+ 지정된 설비({equipProcEquipCount}대)에 등록된 점검항목이 없습니다. 설비정보에서 점검항목을 먼저 등록해주세요. +

) : ( @@ -1186,7 +1236,7 @@ export function DetailFormModal({ }} /> - + @@ -1210,7 +1260,7 @@ export function DetailFormModal({ }} /> - + diff --git a/frontend/lib/utils/validation.ts b/frontend/lib/utils/validation.ts index 43785a9c..ec2d8003 100644 --- a/frontend/lib/utils/validation.ts +++ b/frontend/lib/utils/validation.ts @@ -35,6 +35,9 @@ export function formatField(fieldName: string, value: string): string { case "contact_phone": case "phone": case "cell_phone": + case "fax": + case "fax_number": + case "office_number": return formatPhone(value); case "business_number": return formatBusinessNumber(value); @@ -71,6 +74,9 @@ export function validateField(fieldName: string, value: string): string | null { case "contact_phone": case "phone": case "cell_phone": + case "fax": + case "fax_number": + case "office_number": return validatePhone(value); case "email": return validateEmail(value);
설비코드설비 점검항목 점검방법 기준범위{item.equipment_code || "-"}{item.equipment_name || item.equipment_code || "-"} {item.inspection_item || "-"} {item.inspection_method || "-"} {item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}