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:
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || "삭제 실패");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || "삭제 실패");
|
||||
|
||||
@@ -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] || ""}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || "삭제 실패");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
Reference in New Issue
Block a user