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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user