Enhance Work Instruction and Production Plan Functionality

- Added automatic migration to include a new column `batch_use` in the `item_info` table, allowing for batch usage management.
- Implemented logic to prevent deletion of work instructions that are in progress or completed, ensuring data integrity.
- Enhanced the `getBomBaseQtyMap` function to return batch usage status for items, defaulting to 'Y' if not specified.
- Introduced warnings for overdue items and insufficient production time in the production plan management, allowing users to proceed with caution.

(TASK: ERP-node-074, ERP-node-075, ERP-node-076)
This commit is contained in:
kjs
2026-05-19 16:12:44 +09:00
parent 6731ca4183
commit ffd5ffc4c0
14 changed files with 387 additions and 39 deletions

View File

@@ -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<string, "Y" | "N"> = {};
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 });

View File

@@ -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;

View File

@@ -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<string>();
const tight = new Set<string>();
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 = {

View File

@@ -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 || "삭제 실패");

View File

@@ -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<string>();
const tight = new Set<string>();
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 = {

View File

@@ -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 || "삭제 실패");

View File

@@ -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" ? (
<SmartSelect
value={String(formData[field.key]).toUpperCase() === "N" ? "N" : "Y"}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
options={[{ code: "Y", label: "사용" }, { code: "N", label: "미사용" }]}
placeholder="배치사용여부 선택"
/>
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}

View File

@@ -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<string>();
const tight = new Set<string>();
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 = {

View File

@@ -68,6 +68,10 @@ interface SelectedItem {
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
baseQty?: number | null;
splitMode?: "even" | "sequential";
// 배치사용여부(item_info.batch_use): "Y"=사용(현행 자동분할) / "N"=미사용(수동) — 미지정은 "Y" 간주
batchUse?: "Y" | "N";
// 미사용 품목의 사용자 수동 배치수 (기본 1 = 분할 없음)
manualBatch?: number;
}
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
@@ -81,7 +85,8 @@ function calcBatchCount(qty: number, baseQty: number | null | undefined): number
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
if (batchCount <= 1) return [qty];
if (mode === "sequential") {
// 순차분할은 기준수(baseQty>0)가 있을 때만. 기준수 없으면(미사용 수동분할 등) 균등으로 폴백.
if (mode === "sequential" && baseQty > 0) {
const head = Array(batchCount - 1).fill(baseQty);
const tail = qty - baseQty * (batchCount - 1);
return [...head, tail];
@@ -92,6 +97,17 @@ function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even"
return [...Array(batchCount - 1).fill(base), remainder];
}
// 품목의 실효 배치수 산출
// - 배치 미사용("N"): 사용자 수동 배치수(manualBatch, 기본 1 = 분할 없음). 자동분할 안 함.
// - 배치 사용("Y", 기본): BOM 기준수 기반 자동 산출 (현행 유지)
function effectiveBatchCount(item: { qty?: number; baseQty?: number | null; batchUse?: "Y" | "N"; manualBatch?: number }): number {
if (item.batchUse === "N") {
const mb = Math.floor(Number(item.manualBatch || 1));
return mb >= 1 ? mb : 1;
}
return calcBatchCount(Number(item.qty || 0), item.baseQty);
}
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
interface MultiSelectOption { value: string; label: string; sub?: string; }
interface MultiSelectPopoverProps {
@@ -363,11 +379,16 @@ export default function WorkInstructionPage() {
if (uniqueItemCodes.length > 0) {
getBomBaseQtyMap(uniqueItemCodes).then(r => {
if (r.success && r.data) {
setConfirmItems(prev => prev.map(it => ({
...it,
baseQty: r.data[it.itemCode] ?? null,
splitMode: it.splitMode || "even",
})));
setConfirmItems(prev => prev.map(it => {
const bu: "Y" | "N" = r.batchUse?.[it.itemCode] === "N" ? "N" : "Y";
return {
...it,
baseQty: r.data[it.itemCode] ?? null,
splitMode: it.splitMode || "even",
batchUse: bu,
manualBatch: it.manualBatch ?? 1,
};
}));
}
}).catch(() => {});
}
@@ -407,8 +428,10 @@ export default function WorkInstructionPage() {
for (const i of confirmItems) {
const qty = Number(i.qty || 0);
const baseQty = Number(i.baseQty || 0);
const batchCount = calcBatchCount(qty, i.baseQty);
if (batchCount > 1 && baseQty > 0) {
const isManual = i.batchUse === "N";
const batchCount = effectiveBatchCount(i);
// 사용("Y"): 기존 — baseQty>0 일 때만 분할 / 미사용("N"): 수동 배치수>1 이면 균등 분할
if (batchCount > 1 && (isManual || baseQty > 0)) {
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
for (const p of parts) expandedItems.push({ ...i, _qty: p });
} else {
@@ -536,6 +559,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 || "삭제 실패");
@@ -859,8 +889,9 @@ export default function WorkInstructionPage() {
</TableHeader>
<TableBody>
{confirmItems.map((item, idx) => {
const batchCount = calcBatchCount(Number(item.qty || 0), item.baseQty);
const splitDisabled = batchCount <= 1 || !item.baseQty;
const isManualBatch = item.batchUse === "N";
const batchCount = effectiveBatchCount(item);
const splitDisabled = batchCount <= 1 || (!isManualBatch && !item.baseQty);
return (
<TableRow key={idx} className="bg-background">
<TableCell className="sticky left-0 z-10 bg-background text-[13px] text-center">{idx + 1}</TableCell>
@@ -869,11 +900,29 @@ export default function WorkInstructionPage() {
<TableCell className="sticky left-[360px] z-10 bg-background border-r text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-9 text-sm w-full min-w-[100px]" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{item.baseQty != null && item.baseQty > 0 ? Number(item.baseQty).toLocaleString() : "-"}
</TableCell>
<TableCell className={cn("text-center text-sm font-semibold", batchCount > 1 ? "text-primary" : "text-muted-foreground")}>
{item.baseQty != null && item.baseQty > 0 ? batchCount : "-"}
{/* 미사용 품목은 기준수 무관 → "-" 표시 */}
{!isManualBatch && item.baseQty != null && item.baseQty > 0 ? Number(item.baseQty).toLocaleString() : "-"}
</TableCell>
{isManualBatch ? (
/* 배치 미사용: 배치수 수동 입력 (기본 1 = 분할 없음) */
<TableCell>
<Input
type="number"
min={1}
className="h-9 text-sm w-full min-w-[64px] text-center"
value={item.manualBatch ?? 1}
onChange={e => {
const n = Math.max(1, Math.floor(Number(e.target.value) || 1));
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, manualBatch: n } : it));
}}
title="배치 미사용 품목 — 수동 배치수 (1이면 분할 없음)"
/>
</TableCell>
) : (
<TableCell className={cn("text-center text-sm font-semibold", batchCount > 1 ? "text-primary" : "text-muted-foreground")}>
{item.baseQty != null && item.baseQty > 0 ? batchCount : "-"}
</TableCell>
)}
<TableCell>
<Select
value={item.splitMode || "even"}

View File

@@ -800,15 +800,23 @@ export default function SalesOrderPage() {
try {
const filters: any[] = [];
if (itemSearchKeyword) {
// 다중조건 검색: 쉼표/공백으로 분할한 각 토큰을 품명·품목코드에 OR 매칭 (TASK:ERP-node-080)
const tokens = itemSearchKeyword.split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
const kwFilters = tokens.length > 0
? tokens.flatMap((t) => [
{ columnName: "item_name", operator: "contains", value: t },
{ columnName: "item_number", operator: "contains", value: t },
])
: [
{ columnName: "item_name", operator: "contains", value: itemSearchKeyword },
{ columnName: "item_number", operator: "contains", value: itemSearchKeyword },
];
const kwRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 0,
dataFilter: {
enabled: true,
matchType: "any",
filters: [
{ columnName: "item_name", operator: "contains", value: itemSearchKeyword },
{ columnName: "item_number", operator: "contains", value: itemSearchKeyword },
],
filters: kwFilters,
},
autoFilter: true,
});
@@ -909,6 +917,18 @@ export default function SalesOrderPage() {
searchItems(1);
};
// 검색어 입력 시 디바운스 자동 조회 (엔터/버튼 없이 바로) — TASK:ERP-node-081
useEffect(() => {
if (!itemSelectOpen) return;
const t = setTimeout(() => {
setItemPage(1);
setItemPageInput("1");
searchItems(1);
}, 350);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemSearchKeyword, itemSelectOpen]);
const addSelectedItemsToDetail = async () => {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
@@ -958,6 +978,14 @@ export default function SalesOrderPage() {
} else if (isCustomerPrice && partnerId) {
unitPrice = customerPriceMap[itemCode] || "";
}
// 선택 품목의 단위 자동 반영 — Select value는 카테고리 '코드'라 라벨이 아닌 코드로 저장해야 매칭됨.
// inventory_unit 우선, 없으면 unit. 라벨로 저장돼 있어도 코드로 역해석. (TASK:ERP-node-082)
const unitRaw = item.inventory_unit || item.unit || "";
const unitOpts = (categoryOptions["item_inventory_unit"] || []) as Array<{ code: string; label: string }>;
const unitCode =
unitOpts.find((o) => o.code === unitRaw)?.code ||
unitOpts.find((o) => o.label === unitRaw)?.code ||
unitRaw;
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
@@ -967,7 +995,7 @@ export default function SalesOrderPage() {
pkg_code: "",
pkg_qty_per_unit: "0",
pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>,
unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "",
unit: unitCode,
qty: "1",
pack_count: "0",
unit_price: unitPrice,
@@ -1823,9 +1851,9 @@ export default function SalesOrderPage() {
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -1860,6 +1888,16 @@ export default function SalesOrderPage() {
<span className="text-xs text-muted-foreground"> </span>
)}
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={row.pack_count || "0"}
onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)}
className="h-8 text-xs text-right font-mono w-full"
disabled={!row.pkg_code}
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
@@ -1880,16 +1918,6 @@ export default function SalesOrderPage() {
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={row.pack_count || "0"}
onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)}
className="h-8 text-xs text-right font-mono w-full"
disabled={!row.pkg_code}
/>
</TableCell>
<TableCell>
<Input
value={formatNumber(row.unit_price || "")}
@@ -1974,7 +2002,7 @@ export default function SalesOrderPage() {
</DialogHeader>
<div className="flex gap-2 mb-2">
<Input
placeholder="품명/품목코드 검색"
placeholder="품명/품목코드 검색 (쉼표·공백으로 여러 조건, 하나라도 일치)"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}

View File

@@ -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<string>();
const tight = new Set<string>();
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 = {

View File

@@ -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 || "삭제 실패");

View File

@@ -300,8 +300,12 @@ export default function TimelineScheduler({
laneMap.set(ev.id, 0);
continue;
}
const evStart = parseDate(ev.startDate).getTime();
const evEnd = parseDate(ev.endDate).getTime();
// 잘못된/누락 날짜 가드: NaN이면 lane 계산이 무너져 박스가 겹침.
// start 무효 → 0, end 무효 또는 start 미만 → start 로 보정 (end >= start 보장).
const sRaw = parseDate(ev.startDate).getTime();
const eRaw = parseDate(ev.endDate).getTime();
const evStart = Number.isFinite(sRaw) ? sRaw : 0;
const evEnd = Number.isFinite(eRaw) ? Math.max(eRaw, evStart) : evStart;
let placed = false;
for (let l = 0; l < lanes.length; l++) {
if (evStart > lanes[l].endTime) {
@@ -337,7 +341,8 @@ export default function TimelineScheduler({
// ── 가상 스크롤 (리소스 행) ──
// 대량 리소스(수천~만 단위) 환경에서 보이는 행만 DOM 마운트하여 성능 확보
const barHeight = 24;
const barGap = 2;
// 세로 스택 시 박스 간 간격 — 좁으면 라벨 숫자끼리 붙어 보임. 명확히 분리.
const barGap = 6;
const getRowHeight = useCallback((idx: number) => {
const res = resources[idx];
if (!res) return rowHeight;

View File

@@ -50,7 +50,8 @@ export async function getEmployeeList() {
// BOM 기준수(0레벨 base_qty) 일괄 조회 — 작업지시 등록 모달의 기준수/배치수 산출용
export async function getBomBaseQtyMap(itemCodes: string[]) {
const res = await apiClient.post("/work-instruction/bom-base-qty", { itemCodes });
return res.data as { success: boolean; data: Record<string, number | null> };
// batchUse: 품목별 배치사용여부 (Y=사용/N=미사용). 미포함 시 'Y' 간주 (하위호환)
return res.data as { success: boolean; data: Record<string, number | null>; batchUse?: Record<string, "Y" | "N"> };
}
// ─── 라우팅 & 공정작업기준 API ───