ERP-node/backend-node/src/services/productionPlanService.ts

669 lines
22 KiB
TypeScript

/**
* 생산계획 서비스
* - 수주 데이터 조회 (품목별 그룹핑)
* - 안전재고 부족분 조회
* - 자동 스케줄 생성
* - 스케줄 병합
* - 반제품 계획 자동 생성
* - 스케줄 분할
*/
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
export async function getOrderSummary(
companyCode: string,
options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string }
) {
const pool = getPool();
const conditions: string[] = ["so.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (options?.itemCode) {
conditions.push(`so.part_code ILIKE $${paramIdx}`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
if (options?.itemName) {
conditions.push(`so.part_name ILIKE $${paramIdx}`);
params.push(`%${options.itemName}%`);
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const query = `
WITH order_summary AS (
SELECT
so.part_code AS item_code,
COALESCE(so.part_name, so.part_code) AS item_name,
SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty,
SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty,
SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty,
COUNT(*) AS order_count,
MIN(so.due_date) AS earliest_due_date
FROM sales_order_mng so
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
stock_info AS (
SELECT
item_code,
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
FROM inventory_stock
WHERE company_code = $1
GROUP BY item_code
),
plan_info AS (
SELECT
item_code,
SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty,
SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty
FROM production_plan_mng
WHERE company_code = $1
AND COALESCE(product_type, '완제품') = '완제품'
AND status NOT IN ('completed', 'cancelled')
GROUP BY item_code
)
SELECT
os.item_code,
os.item_name,
os.total_order_qty,
os.total_ship_qty,
os.total_balance_qty,
os.order_count,
os.earliest_due_date,
COALESCE(si.current_stock, 0) AS current_stock,
COALESCE(si.safety_stock, 0) AS safety_stock,
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
GREATEST(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
const result = await pool.query(query, params);
// 그룹별 상세 수주 데이터도 함께 조회
const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND ");
const detailQuery = `
SELECT
id, order_no, part_code, part_name,
COALESCE(order_qty::numeric, 0) AS order_qty,
COALESCE(ship_qty::numeric, 0) AS ship_qty,
COALESCE(balance_qty::numeric, 0) AS balance_qty,
due_date, status, partner_id, manager_name
FROM sales_order_mng
WHERE ${detailWhere}
ORDER BY part_code, due_date;
`;
const detailResult = await pool.query(detailQuery, params);
// 그룹별로 상세 데이터 매핑
const ordersByItem: Record<string, any[]> = {};
for (const row of detailResult.rows) {
const key = row.part_code || "__null__";
if (!ordersByItem[key]) ordersByItem[key] = [];
ordersByItem[key].push(row);
}
const data = result.rows.map((group: any) => ({
...group,
orders: ordersByItem[group.item_code || "__null__"] || [],
}));
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length });
return data;
}
// ─── 안전재고 부족분 조회 ───
export async function getStockShortage(companyCode: string) {
const pool = getPool();
const query = `
SELECT
ist.item_code,
ii.item_name,
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
GREATEST(
COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0
) AS recommended_qty,
ist.last_in_date
FROM inventory_stock ist
LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
WHERE ist.company_code = $1
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
ORDER BY shortage_qty ASC;
`;
const result = await pool.query(query, [companyCode]);
logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
const pool = getPool();
const result = await pool.query(
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
[planId, companyCode]
);
return result.rows[0] || null;
}
export async function updatePlan(
companyCode: string,
planId: number,
data: Record<string, any>,
updatedBy: string
) {
const pool = getPool();
const allowedFields = [
"plan_qty", "start_date", "end_date", "due_date",
"equipment_id", "equipment_code", "equipment_name",
"manager_name", "work_shift", "priority", "remarks", "status",
"item_code", "item_name", "product_type", "order_no",
];
const setClauses: string[] = [];
const params: any[] = [];
let paramIdx = 1;
for (const field of allowedFields) {
if (data[field] !== undefined) {
setClauses.push(`${field} = $${paramIdx}`);
params.push(data[field]);
paramIdx++;
}
}
if (setClauses.length === 0) {
throw new Error("수정할 필드가 없습니다");
}
setClauses.push(`updated_date = NOW()`);
setClauses.push(`updated_by = $${paramIdx}`);
params.push(updatedBy);
paramIdx++;
params.push(planId);
params.push(companyCode);
const query = `
UPDATE production_plan_mng
SET ${setClauses.join(", ")}
WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx}
RETURNING *
`;
const result = await pool.query(query, params);
if (result.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
}
logger.info("생산계획 수정", { companyCode, planId });
return result.rows[0];
}
export async function deletePlan(companyCode: string, planId: number) {
const pool = getPool();
const result = await pool.query(
`DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[planId, companyCode]
);
if (result.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
}
logger.info("생산계획 삭제", { companyCode, planId });
return { id: planId };
}
// ─── 자동 스케줄 생성 ───
interface GenerateScheduleItem {
item_code: string;
item_name: string;
required_qty: number;
earliest_due_date: string;
hourly_capacity?: number;
daily_capacity?: number;
lead_time?: number;
}
interface GenerateScheduleOptions {
safety_lead_time?: number;
recalculate_unstarted?: boolean;
product_type?: string;
}
export async function generateSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions,
createdBy: string
) {
const pool = getPool();
const client = await pool.connect();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
try {
await client.query("BEGIN");
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'
RETURNING id`,
[companyCode, item.item_code, productType]
);
deletedCount += deleteResult.rowCount || 0;
const keptResult = await client.query(
`SELECT COUNT(*) AS cnt 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]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
// 생산일수 계산
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 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]
);
const nextNo = planNoResult.rows[0].next_no || 1;
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, priority, hourly_capacity, daily_capacity, lead_time,
created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
'planned', 'normal', $10, $11, $12,
$13, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, item.item_code, item.item_name,
productType, requiredQty,
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
item.earliest_due_date,
item.hourly_capacity || 100,
dailyCapacity,
item.lead_time || 1,
createdBy,
]
);
newSchedules.push(insertResult.rows[0]);
}
await client.query("COMMIT");
const summary = {
total: newSchedules.length + keptCount,
new_count: newSchedules.length,
kept_count: keptCount,
deleted_count: deletedCount,
};
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
return { summary, schedules: newSchedules };
} catch (error) {
await client.query("ROLLBACK");
logger.error("자동 스케줄 생성 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 스케줄 병합 ───
export async function mergeSchedules(
companyCode: string,
scheduleIds: number[],
productType: string,
mergedBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 대상 스케줄 조회
const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", ");
const targetResult = await client.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
ORDER BY start_date`,
[companyCode, ...scheduleIds]
);
if (targetResult.rowCount !== scheduleIds.length) {
throw new Error("일부 스케줄을 찾을 수 없습니다");
}
const rows = targetResult.rows;
// 동일 품목 검증
const itemCodes = [...new Set(rows.map((r: any) => r.item_code))];
if (itemCodes.length > 1) {
throw new Error("동일 품목의 스케줄만 병합할 수 있습니다");
}
// 병합 값 계산
const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0);
const earliestStart = rows.reduce(
(min: string, r: any) => (!min || r.start_date < min ? r.start_date : min),
""
);
const latestEnd = rows.reduce(
(max: string, r: any) => (!max || r.end_date > max ? r.end_date : max),
""
);
const earliestDue = rows.reduce(
(min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min),
""
);
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", ");
// 기존 삭제
await client.query(
`DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...scheduleIds]
);
// 병합된 스케줄 생성
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]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, order_no, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
'planned', $10, $11, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, rows[0].item_code, rows[0].item_name,
productType, totalQty,
earliestStart, latestEnd, earliestDue || null,
orderNos || null, mergedBy,
]
);
await client.query("COMMIT");
logger.info("스케줄 병합 완료", {
companyCode,
mergedFrom: scheduleIds,
mergedTo: insertResult.rows[0].id,
});
return insertResult.rows[0];
} catch (error) {
await client.query("ROLLBACK");
logger.error("스케줄 병합 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean },
createdBy: string
) {
const pool = getPool();
const client = await pool.connect();
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})`,
[companyCode, ...planIds]
);
const newSemiPlans: any[] = [];
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]);
for (const bomItem of bomResult.rows) {
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
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 semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
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]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, parent_plan_id, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
'반제품', $5, $6, $7, $8,
'planned', $9, $10, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo,
bomItem.child_item_code || bomItem.child_item_id,
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],
plan.id,
createdBy,
]
);
newSemiPlans.push(insertResult.rows[0]);
}
}
await client.query("COMMIT");
logger.info("반제품 계획 생성 완료", {
companyCode,
parentPlanIds: planIds,
semiPlanCount: newSemiPlans.length,
});
return { count: newSemiPlans.length, schedules: newSemiPlans };
} catch (error) {
await client.query("ROLLBACK");
logger.error("반제품 계획 생성 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 스케줄 분할 ───
export async function splitSchedule(
companyCode: string,
planId: number,
splitQty: number,
splitBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const planResult = await client.query(
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
[planId, companyCode]
);
if (planResult.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없습니다");
}
const plan = planResult.rows[0];
const originalQty = parseFloat(plan.plan_qty) || 0;
if (splitQty >= originalQty || splitQty <= 0) {
throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다");
}
// 원본 수량 감소
await client.query(
`UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2
WHERE id = $3 AND company_code = $4`,
[originalQty - splitQty, splitBy, planId, companyCode]
);
// 분할된 새 계획 생성
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]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, priority, equipment_id, equipment_code, equipment_name,
order_no, parent_plan_id, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, plan.item_code, plan.item_name,
plan.product_type, splitQty,
plan.start_date, plan.end_date, plan.due_date,
plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name,
plan.order_no, plan.parent_plan_id,
splitBy,
]
);
await client.query("COMMIT");
logger.info("스케줄 분할 완료", { companyCode, planId, splitQty });
return {
original: { id: planId, plan_qty: originalQty - splitQty },
split: insertResult.rows[0],
};
} catch (error) {
await client.query("ROLLBACK");
logger.error("스케줄 분할 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}