feat: add schedule preview functionality for production plans

- Implemented previewSchedule and previewSemiSchedule functions in the production controller to allow users to preview schedule changes without making actual database modifications.
- Added corresponding routes for schedule preview in productionRoutes.
- Enhanced productionPlanService with logic to generate schedule previews based on provided items and plan IDs.
- Introduced SchedulePreviewDialog component to display the preview results in the frontend, including summary and detailed views of planned schedules.

These updates improve the user experience by providing a way to visualize scheduling changes before applying them, ensuring better planning and decision-making.

Made-with: Cursor
This commit is contained in:
kjs
2026-03-16 14:00:07 +09:00
parent 5cdbd2446b
commit 64c9f25f63
16 changed files with 2515 additions and 97 deletions

View File

@@ -95,6 +95,25 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) {
}
}
// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ───
export async function previewSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { items, options } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" });
}
const data = await productionService.previewSchedule(companyCode, items, options || {});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("자동 스케줄 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 자동 스케줄 생성 ───
export async function generateSchedule(req: AuthenticatedRequest, res: Response) {
@@ -141,6 +160,29 @@ export async function mergeSchedules(req: AuthenticatedRequest, res: Response) {
}
}
// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ───
export async function previewSemiSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { plan_ids, options } = req.body;
if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) {
return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" });
}
const data = await productionService.previewSemiSchedule(
companyCode,
plan_ids,
options || {}
);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("반제품 계획 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) {

View File

@@ -21,12 +21,18 @@ router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);
router.delete("/plan/:id", productionController.deletePlan);
// 자동 스케줄 미리보기 (실제 변경 없이 예상 결과)
router.post("/generate-schedule/preview", productionController.previewSchedule);
// 자동 스케줄 생성
router.post("/generate-schedule", productionController.generateSchedule);
// 스케줄 병합
router.post("/merge-schedules", productionController.mergeSchedules);
// 반제품 계획 미리보기
router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule);
// 반제품 계획 자동 생성
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);

View File

@@ -251,6 +251,101 @@ interface GenerateScheduleOptions {
product_type?: string;
}
/**
* 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환)
*/
export async function previewSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions
) {
const pool = getPool();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
const previews: any[] = [];
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
);
keptSchedules.push(...keptResult.rows);
}
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
}
// 해당 품목의 수주 건수 확인
const orderCountResult = await pool.query(
`SELECT COUNT(*) AS cnt FROM sales_order_mng
WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`,
[companyCode, item.item_code]
);
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
previews.push({
item_code: item.item_code,
item_name: item.item_name,
required_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,
order_count: orderCount,
status: "planned",
});
}
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
kept_count: keptSchedules.length,
deleted_count: deletedSchedules.length,
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
companyCode: string,
items: GenerateScheduleItem[],
@@ -317,14 +412,16 @@ export async function generateSchedule(
endDate.setDate(endDate.getDate() + productionDays);
}
// 계획번호 생성
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const nextNo = planNoResult.rows[0].next_no || 1;
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@@ -472,6 +569,123 @@ export async function mergeSchedules(
}
}
// ─── 반제품 BOM 소요량 조회 (공통) ───
async function getBomChildItems(
client: any,
companyCode: string,
itemCode: string
) {
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
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
WHERE b.company_code = $1
AND b.item_code = $2
AND COALESCE(b.status, 'active') = 'active'
`;
const result = await client.query(bomQuery, [companyCode, itemCode]);
return result.rows;
}
// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ───
export async function previewSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean }
) {
const pool = getPool();
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
const previews: any[] = [];
const existingSemiPlans: any[] = [];
for (const plan of plansResult.rows) {
// 이미 존재하는 반제품 계획 조회
const existingResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`,
[companyCode, plan.id]
);
existingSemiPlans.push(...existingResult.rows);
const bomItems = await getBomChildItems(pool, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await pool.query(
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2`,
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
);
const stock = parseFloat(stockResult.rows[0].stock) || 0;
requiredQty = Math.max(requiredQty - stock, 0);
}
if (requiredQty <= 0) continue;
const semiDueDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
previews.push({
parent_plan_id: plan.id,
parent_plan_no: plan.plan_no,
parent_item_name: plan.item_name,
item_code: bomItem.child_item_code || bomItem.child_item_id,
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
}
}
// 기존 반제품 중 삭제 대상 (status = planned)
const deletedSchedules = existingSemiPlans.filter(
(s) => s.status === "planned"
);
// 기존 반제품 중 유지 대상 (진행중 등)
const keptSchedules = existingSemiPlans.filter(
(s) => s.status !== "planned" && s.status !== "completed"
);
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
deleted_count: deletedSchedules.length,
kept_count: keptSchedules.length,
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(
@@ -486,41 +700,36 @@ export async function generateSemiSchedule(
try {
await client.query("BEGIN");
// 선택된 완제품 계획 조회
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await client.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})`,
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
// 기존 planned 상태 반제품 삭제
for (const plan of plansResult.rows) {
await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2
AND product_type = '반제품' AND status = 'planned'`,
[companyCode, plan.id]
);
}
const newSemiPlans: any[] = [];
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
for (const plan of plansResult.rows) {
// BOM에서 해당 품목의 반제품 소요량 조회
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_code AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
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
WHERE b.company_code = $1
AND b.item_code = $2
AND COALESCE(b.status, 'active') = 'active'
`;
const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]);
const bomItems = await getBomChildItems(client, companyCode, plan.item_code);
for (const bomItem of bomResult.rows) {
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
// 재고 고려
if (options.considerStock) {
const stockResult = await client.query(
`SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2`,
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
@@ -531,18 +740,20 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품 납기일 = 완제품 시작일
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-S%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@@ -560,8 +771,8 @@ export async function generateSemiSchedule(
bomItem.child_item_name || bomItem.child_item_id,
requiredQty,
semiStartDate.toISOString().split("T")[0],
typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0],
typeof semiEndDate === "string" ? semiEndDate.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0],
plan.id,
createdBy,
]