diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 21e094d2..28f610be 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -25,6 +25,19 @@ async function ensureDetailRoutingColumn() { } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } +// 자동 마이그레이션: item_info에 batch_use(배치사용여부) 컬럼 추가 (TASK:ERP-node-074) +// 'Y'=사용(현행 유지, 기본) / 'N'=미사용(작업지시 자동 배치분할 안 함) +let _batchUseMigrationDone = false; +async function ensureItemInfoBatchUseColumn() { + if (_batchUseMigrationDone) return; + try { + const pool = getPool(); + await pool.query("ALTER TABLE item_info ADD COLUMN IF NOT EXISTS batch_use VARCHAR(1) DEFAULT 'Y'"); + await pool.query("UPDATE item_info SET batch_use = 'Y' WHERE batch_use IS NULL OR batch_use = ''"); + _batchUseMigrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + // ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── export async function getList(req: AuthenticatedRequest, res: Response) { try { @@ -361,6 +374,31 @@ export async function remove(req: AuthenticatedRequest, res: Response) { if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" }); const pool = getPool(); + + // 진행중/완료 작업지시는 삭제 불가 (데이터 무결성 가드, TASK:ERP-node-075) + // progress_status: in_progress/completed → 차단. NULL이라도 실적(completed_qty)이 있으면 진행으로 간주. + const guard = await pool.query( + `SELECT work_instruction_no, + progress_status, + CASE WHEN completed_qty ~ '^[0-9]+(\\.[0-9]+)?$' THEN completed_qty::numeric ELSE 0 END AS completed_qty + FROM work_instruction + WHERE id = ANY($1) AND company_code = $2`, + [ids, companyCode] + ); + const blocked = guard.rows.filter((r: any) => { + const ps = String(r.progress_status || "").toLowerCase(); + if (ps === "in_progress" || ps === "completed") return true; + if (!ps || ps === "pending") return Number(r.completed_qty) > 0; + return false; + }); + if (blocked.length > 0) { + const nos = blocked.map((r: any) => r.work_instruction_no).filter(Boolean).join(", "); + return res.status(409).json({ + success: false, + message: `진행중이거나 완료된 작업지시는 삭제할 수 없습니다.${nos ? ` (${nos})` : ""}`, + }); + } + const client = await pool.connect(); try { await client.query("BEGIN"); @@ -1008,8 +1046,9 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) try { const companyCode = req.user!.companyCode; const itemCodes: string[] = Array.isArray(req.body?.itemCodes) ? req.body.itemCodes.filter(Boolean) : []; - if (itemCodes.length === 0) return res.json({ success: true, data: {} }); + if (itemCodes.length === 0) return res.json({ success: true, data: {}, batchUse: {} }); + await ensureItemInfoBatchUseColumn(); const pool = getPool(); // bom.item_code 우선 매칭, 없으면 item_info.id 경유 매칭 const result = await pool.query( @@ -1032,7 +1071,23 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) if (map[code] == null) map[code] = base; } } - return res.json({ success: true, data: map }); + + // 품목별 배치사용여부 일괄 조회 (BOM 유무와 무관하게 item_info 기준, TASK:ERP-node-074) + // 빈 값/NULL/미등록 품목은 'Y'(사용, 현행 유지)로 간주 + const batchUse: Record = {}; + for (const code of itemCodes) batchUse[code] = "Y"; + const buResult = await pool.query( + `SELECT item_number, COALESCE(NULLIF(batch_use, ''), 'Y') AS batch_use + FROM item_info + WHERE company_code = $1 AND item_number = ANY($2::text[])`, + [companyCode, itemCodes] + ); + for (const row of buResult.rows) { + if (!row.item_number) continue; + batchUse[row.item_number] = String(row.batch_use).toUpperCase() === "N" ? "N" : "Y"; + } + + return res.json({ success: true, data: map, batchUse }); } catch (error: any) { logger.error("BOM 기준수 일괄 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index a9aa07e4..48b008f9 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -945,10 +945,23 @@ async function getBomChildItems( ${leadTimeCol} AS child_lead_time FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code - LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code + JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code WHERE b.company_code = $1 AND b.item_code = $2 AND COALESCE(b.status, 'active') = 'active' + -- 반제품 계획은 BOM 자식 중 품목구분이 '반제품'인 것만 대상 (TASK:ERP-node-077) + -- item_info.type 은 회사/저장경로에 따라 라벨('반제품') 또는 카테고리 코드(CAT_xxx)로 들어옴. + -- category_values 로 코드↔라벨을 함께 해석하여 양쪽 모두 매칭. (TASK:ERP-node-077 후속) + AND ( + TRIM(COALESCE(ii.type, '')) = '반제품' + OR ii.type IN ( + SELECT value_code FROM category_values + WHERE company_code = $1 + AND table_name = 'item_info' + AND column_name = 'type' + AND TRIM(COALESCE(value_label, '')) = '반제품' + ) + ) `; const result = await client.query(bomQuery, [companyCode, itemCode]); return result.rows; diff --git a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index cd159a0b..8fde0ca5 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -528,6 +528,47 @@ export default function ProductionPlanManagementPage() { return; } + // 납기일 경과 / 납기 대비 생산시간(품목 리드타임) 부족 경고 — 경고만, 진행 가능 (TASK:ERP-node-076) + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const overdue = new Set(); + const tight = new Set(); + const DAILY_CAP = 800; // 백엔드 기본 일생산능력(productionPlanService 기본값)과 일치 + for (const it of items) { + const due = new Date(it.earliest_due_date); + if (isNaN(due.getTime())) continue; + due.setHours(0, 0, 0, 0); + const lt = Number(it.lead_time) || 0; + const nm = it.item_name || it.item_code; + if (due < today) { + overdue.add(`${nm}(납기 ${it.earliest_due_date})`); + continue; + } + const daysLeft = Math.ceil((due.getTime() - today.getTime()) / 86400000); + if (lt > 0) { + // 리드타임 설정됨: 리드타임(일) 기준 부족 판정 + if (lt > daysLeft) tight.add(`${nm}(리드타임 ${lt}일 > 납기까지 ${daysLeft}일)`); + } else { + // 리드타임 미설정: 생산능력 기반 소요일 추정으로 부족 판정 + const prodDays = Math.ceil((Number(it.required_qty) || 0) / DAILY_CAP); + if (prodDays > daysLeft) tight.add(`${nm}(생산소요 약 ${prodDays}일 > 납기까지 ${daysLeft}일)`); + } + } + const warn: string[] = []; + if (overdue.size > 0) warn.push(`· 납기일 경과: ${[...overdue].join(", ")}`); + if (tight.size > 0) warn.push(`· 납기 대비 생산시간 부족: ${[...tight].join(", ")}`); + if (warn.length > 0) { + const ok = await confirm("납기 일정이 부족합니다. 그래도 생산계획을 생성할까요?", { + description: warn.join("\n"), + confirmText: "계획 생성", + cancelText: "취소", + variant: "destructive", + }); + if (!ok) return; + } + } + setGenerating(true); try { const req: GenerateScheduleRequest = { diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index 110b2b4d..e5ec0009 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -485,6 +485,13 @@ export default function WorkInstructionPage() { }; const handleDelete = async (wiId: string) => { + // 진행중/완료 작업지시는 삭제 불가 (TASK:ERP-node-075) + const target = orders.find((o) => o.wi_id === wiId); + const label = target ? getProgressLabel(target) : ""; + if (label === "진행중" || label === "완료") { + alert(`${label} 상태인 작업지시는 삭제할 수 없습니다.`); + return; + } if (!confirm("이 작업지시를 삭제하시겠습니까?")) return; const r = await deleteWorkInstructions([wiId]); if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패"); diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index cd159a0b..8fde0ca5 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -528,6 +528,47 @@ export default function ProductionPlanManagementPage() { return; } + // 납기일 경과 / 납기 대비 생산시간(품목 리드타임) 부족 경고 — 경고만, 진행 가능 (TASK:ERP-node-076) + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const overdue = new Set(); + const tight = new Set(); + const DAILY_CAP = 800; // 백엔드 기본 일생산능력(productionPlanService 기본값)과 일치 + for (const it of items) { + const due = new Date(it.earliest_due_date); + if (isNaN(due.getTime())) continue; + due.setHours(0, 0, 0, 0); + const lt = Number(it.lead_time) || 0; + const nm = it.item_name || it.item_code; + if (due < today) { + overdue.add(`${nm}(납기 ${it.earliest_due_date})`); + continue; + } + const daysLeft = Math.ceil((due.getTime() - today.getTime()) / 86400000); + if (lt > 0) { + // 리드타임 설정됨: 리드타임(일) 기준 부족 판정 + if (lt > daysLeft) tight.add(`${nm}(리드타임 ${lt}일 > 납기까지 ${daysLeft}일)`); + } else { + // 리드타임 미설정: 생산능력 기반 소요일 추정으로 부족 판정 + const prodDays = Math.ceil((Number(it.required_qty) || 0) / DAILY_CAP); + if (prodDays > daysLeft) tight.add(`${nm}(생산소요 약 ${prodDays}일 > 납기까지 ${daysLeft}일)`); + } + } + const warn: string[] = []; + if (overdue.size > 0) warn.push(`· 납기일 경과: ${[...overdue].join(", ")}`); + if (tight.size > 0) warn.push(`· 납기 대비 생산시간 부족: ${[...tight].join(", ")}`); + if (warn.length > 0) { + const ok = await confirm("납기 일정이 부족합니다. 그래도 생산계획을 생성할까요?", { + description: warn.join("\n"), + confirmText: "계획 생성", + cancelText: "취소", + variant: "destructive", + }); + if (!ok) return; + } + } + setGenerating(true); try { const req: GenerateScheduleRequest = { diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index 064a7dae..39362546 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -489,6 +489,13 @@ export default function WorkInstructionPage() { }; const handleDelete = async (wiId: string) => { + // 진행중/완료 작업지시는 삭제 불가 (TASK:ERP-node-075) + const target = orders.find((o) => o.wi_id === wiId); + const label = target ? getProgressLabel(target) : ""; + if (label === "진행중" || label === "완료") { + alert(`${label} 상태인 작업지시는 삭제할 수 없습니다.`); + return; + } if (!confirm("이 작업지시를 삭제하시겠습니까?")) return; const r = await deleteWorkInstructions([wiId]); if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패"); diff --git a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx index 7ba34a33..30b8527e 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx @@ -40,6 +40,7 @@ import { ImageUpload } from "@/components/common/ImageUpload"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { pickCategoryOptions } from "@/lib/utils/categoryFlatten"; @@ -147,6 +148,7 @@ const GRID_COLUMNS = [ { key: "unit", label: "단위" }, { key: "material", label: "재질" }, { key: "status", label: "상태" }, + { key: "batch_use", label: "배치사용여부" }, { key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true }, { key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true }, { key: "weight", label: "중량", align: "right" as const }, @@ -165,6 +167,7 @@ const FORM_FIELDS = [ { key: "unit", label: "단위", type: "category" }, { key: "material", label: "재질", type: "category" }, { key: "status", label: "상태", type: "category" }, + { key: "batch_use", label: "배치사용여부", type: "yn" }, { key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" }, { key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" }, { key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" }, @@ -395,6 +398,8 @@ export default function ItemInfoPage() { for (const col of CATEGORY_COLUMNS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } + // 배치사용여부: 코드(Y/N) → 라벨. NULL/빈값/Y = 사용(현행 유지) + converted.batch_use = String(r.batch_use).toUpperCase() === "N" ? "미사용" : "사용"; return converted; }); }, [rawItems, categoryOptions]); @@ -450,7 +455,7 @@ export default function ItemInfoPage() { // 등록 모달 열기 const openRegisterModal = async () => { - setFormData({}); + setFormData({ batch_use: "Y" }); setManualInputValue(""); setNumberingParts([]); setIsEditMode(false); @@ -765,6 +770,13 @@ export default function ItemInfoPage() { onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))} placeholder={`${field.label} 선택`} /> + ) : field.type === "yn" ? ( + setFormData((prev) => ({ ...prev, [field.key]: v }))} + options={[{ code: "Y", label: "사용" }, { code: "N", label: "미사용" }]} + placeholder="배치사용여부 선택" + /> ) : field.type === "textarea" ? (