Implement production plan listing feature with API and frontend integration

This commit is contained in:
kjs
2026-03-23 11:11:44 +09:00
parent ca2af56aad
commit aa48d40048
6 changed files with 1753 additions and 18 deletions

View File

@@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
}
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { productType, status, startDate, endDate, itemCode } = req.query;
const data = await productionService.getPlans(companyCode, {
productType: productType as string,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
itemCode: itemCode as string,
});
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 getPlanById(req: AuthenticatedRequest, res: Response) {

View File

@@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);

View File

@@ -155,6 +155,80 @@ export async function getStockShortage(companyCode: string) {
return result.rows;
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(
companyCode: string,
options?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}
) {
const pool = getPool();
const conditions: string[] = ["p.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (companyCode !== "*") {
// 일반 회사: 자사 데이터만
} else {
// 최고관리자: 전체 데이터 (company_code 조건 제거)
conditions.length = 0;
}
if (options?.productType) {
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
params.push(options.productType);
paramIdx++;
}
if (options?.status && options.status !== "all") {
conditions.push(`p.status = $${paramIdx}`);
params.push(options.status);
paramIdx++;
}
if (options?.startDate) {
conditions.push(`p.end_date >= $${paramIdx}::date`);
params.push(options.startDate);
paramIdx++;
}
if (options?.endDate) {
conditions.push(`p.start_date <= $${paramIdx}::date`);
params.push(options.endDate);
paramIdx++;
}
if (options?.itemCode) {
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id, p.company_code, p.plan_no, p.plan_date,
p.item_code, p.item_name, p.product_type,
p.plan_qty, p.completed_qty, p.progress_rate,
p.start_date, p.end_date, p.due_date,
p.equipment_id, p.equipment_code, p.equipment_name,
p.status, p.priority, p.work_shift,
p.work_order_no, p.manager_name,
p.order_no, p.parent_plan_id, p.remarks,
p.hourly_capacity, p.daily_capacity, p.lead_time,
p.created_date, p.updated_date
FROM production_plan_mng p
${whereClause}
ORDER BY p.start_date ASC, p.item_code ASC
`;
const result = await pool.query(query, params);
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
@@ -293,7 +367,18 @@ export async function previewSchedule(
}
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
let requiredQty = item.required_qty;
// recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로,
// 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨
if (options.recalculate_unstarted) {
const deletedQtyForItem = deletedSchedules
.filter((d: any) => d.item_code === item.item_code)
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
requiredQty += deletedQtyForItem;
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
@@ -343,7 +428,7 @@ export async function previewSchedule(
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
@@ -365,7 +450,21 @@ export async function generateSchedule(
const newSchedules: any[] = [];
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
// 삭제 전에 기존 planned 수량 먼저 조회
let deletedQtyForItem = 0;
if (options.recalculate_unstarted) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
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]
);
deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0;
}
// 기존 미진행(planned) 스케줄 삭제
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
@@ -389,9 +488,9 @@ export async function generateSchedule(
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
// 생산일수 계산
// 필요 수량 계산 (삭제된 planned 수량을 복원)
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
let requiredQty = item.required_qty + deletedQtyForItem;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
@@ -683,7 +782,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───