Files
vexplor/backend-node/src/services/productionPlanService.ts
kjs 2e9b67a509 feat: COMPANY_9 수주관리 페이지 추가 및 생산계획/공정 개선
- COMPANY_9 수주관리(sales/order) 하드코딩 페이지 추가
- 생산계획 서비스 로직 정리 및 공정 컨트롤러 수정
- COMPANY_7 생산계획관리 페이지 개선
- AdminPageRenderer 기능 확장
2026-03-27 22:32:18 +09:00

1049 lines
35 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 ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
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
),
${itemLeadTimeCte}
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,
COALESCE(ilt.lead_time, 0) AS lead_time
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
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${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;
}
// ─── 생산계획 목록 조회 ───
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) {
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;
}
/**
* 자동 스케줄 미리보기 (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[] = [];
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
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, itemCode, 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, itemCode, productType]
);
keptSchedules.push(...keptResult.rows);
}
}
for (const item of items) {
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + duration);
}
// 해당 품목의 수주 건수 확인
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: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
lead_time: itemLeadTime,
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, schedules: previews, deletedSchedules, keptSchedules };
}
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[] = [];
const deletedQtyByItem = new Map<string, number>();
// 같은 item_code에 대한 삭제는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
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, itemCode, productType]
);
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
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, itemCode, 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, itemCode, productType]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
}
for (const item of items) {
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + duration);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
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 (
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();
}
}
// ─── 반제품 BOM 소요량 조회 (공통) ───
async function getBomChildItems(
client: any,
companyCode: string,
itemCode: string
) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
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,
${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
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 childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
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,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: semiEndDate.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, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
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})
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) {
const bomItems = await getBomChildItems(client, 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 client.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 childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-S%`]
);
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 (
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.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,
]
);
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();
}
}