Merge remote-tracking branch 'origin/main' into kwshin-node
This commit is contained in:
@@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
@@ -124,6 +125,7 @@ import entitySearchRoutes, {
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
@@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
@@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
||||
@@ -314,13 +314,14 @@ router.post(
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
const { formData, manualInputValue } = req.body;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
formData,
|
||||
manualInputValue
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
|
||||
291
backend-node/src/controllers/popProductionController.ts
Normal file
291
backend-node/src/controllers/popProductionController.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
|
||||
/**
|
||||
* D-BE1: 작업지시 공정 일괄 생성
|
||||
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
|
||||
*/
|
||||
export const createWorkProcesses = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
|
||||
req.body;
|
||||
|
||||
if (!work_instruction_id || !routing_version_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"work_instruction_id와 routing_version_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] create-work-processes 요청", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_instruction_id,
|
||||
item_code,
|
||||
routing_version_id,
|
||||
plan_qty,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
|
||||
const existCheck = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||
WHERE wo_id = $1 AND company_code = $2`,
|
||||
[work_instruction_id, companyCode]
|
||||
);
|
||||
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 공정이 생성된 작업지시입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
|
||||
const routingDetails = await client.query(
|
||||
`SELECT rd.id, rd.seq_no, rd.process_code,
|
||||
COALESCE(pm.process_name, rd.process_code) as process_name,
|
||||
rd.is_required, rd.is_fixed_order, rd.standard_time
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
|
||||
AND pm.company_code = rd.company_code
|
||||
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
|
||||
[routing_version_id, companyCode]
|
||||
);
|
||||
|
||||
if (routingDetails.rows.length === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "라우팅 버전에 등록된 공정이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const processes: Array<{
|
||||
id: string;
|
||||
seq_no: string;
|
||||
process_name: string;
|
||||
checklist_count: number;
|
||||
}> = [];
|
||||
let totalChecklists = 0;
|
||||
|
||||
for (const rd of routingDetails.rows) {
|
||||
// 2. work_order_process INSERT
|
||||
const wopResult = await client.query(
|
||||
`INSERT INTO work_order_process (
|
||||
company_code, wo_id, seq_no, process_code, process_name,
|
||||
is_required, is_fixed_order, standard_time, plan_qty,
|
||||
status, routing_detail_id, writer
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode,
|
||||
work_instruction_id,
|
||||
rd.seq_no,
|
||||
rd.process_code,
|
||||
rd.process_name,
|
||||
rd.is_required,
|
||||
rd.is_fixed_order,
|
||||
rd.standard_time,
|
||||
plan_qty || null,
|
||||
"waiting",
|
||||
rd.id,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
const wopId = wopResult.rows[0].id;
|
||||
|
||||
// 3. process_work_result INSERT (스냅샷 복사)
|
||||
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
|
||||
const snapshotResult = await client.query(
|
||||
`INSERT INTO process_work_result (
|
||||
company_code, work_order_process_id,
|
||||
source_work_item_id, source_detail_id,
|
||||
work_phase, item_title, item_sort_order,
|
||||
detail_content, detail_type, detail_sort_order, is_required,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
input_type, lookup_target, display_fields, duration_minutes,
|
||||
status, writer
|
||||
)
|
||||
SELECT
|
||||
pwi.company_code, $1,
|
||||
pwi.id, pwd.id,
|
||||
pwi.work_phase, pwi.title, pwi.sort_order::text,
|
||||
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
|
||||
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
|
||||
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
|
||||
'pending', $2
|
||||
FROM process_work_item pwi
|
||||
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
|
||||
AND pwd.company_code = pwi.company_code
|
||||
WHERE pwi.routing_detail_id = $3
|
||||
AND pwi.company_code = $4
|
||||
ORDER BY pwi.sort_order, pwd.sort_order`,
|
||||
[wopId, userId, rd.id, companyCode]
|
||||
);
|
||||
|
||||
const checklistCount = snapshotResult.rowCount ?? 0;
|
||||
totalChecklists += checklistCount;
|
||||
|
||||
processes.push({
|
||||
id: wopId,
|
||||
seq_no: rd.seq_no,
|
||||
process_name: rd.process_name,
|
||||
checklist_count: checklistCount,
|
||||
});
|
||||
|
||||
logger.info("[pop/production] 공정 생성 완료", {
|
||||
wopId,
|
||||
processName: rd.process_name,
|
||||
checklistCount,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/production] create-work-processes 완료", {
|
||||
companyCode,
|
||||
work_instruction_id,
|
||||
total_processes: processes.length,
|
||||
total_checklists: totalChecklists,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
processes,
|
||||
total_processes: processes.length,
|
||||
total_checklists: totalChecklists,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("[pop/production] create-work-processes 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "공정 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* D-BE2: 타이머 API (시작/일시정지/재시작)
|
||||
*/
|
||||
export const controlTimer = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const { work_order_process_id, action } = req.body;
|
||||
|
||||
if (!work_order_process_id || !action) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "work_order_process_id와 action은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!["start", "pause", "resume"].includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "action은 start, pause, resume 중 하나여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] timer 요청", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_order_process_id,
|
||||
action,
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case "start":
|
||||
// 최초 1회만 설정, 이미 있으면 무시
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
|
||||
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, started_at, status`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
|
||||
case "pause":
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET paused_at = NOW()::text,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
|
||||
RETURNING id, paused_at`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
|
||||
case "resume":
|
||||
// 일시정지 시간 누적 후 paused_at 초기화
|
||||
result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET total_paused_time = (
|
||||
COALESCE(total_paused_time::int, 0)
|
||||
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
|
||||
)::text,
|
||||
paused_at = NULL,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
|
||||
RETURNING id, total_paused_time`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result || result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("[pop/production] timer 완료", {
|
||||
action,
|
||||
work_order_process_id,
|
||||
result: result.rows[0],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] timer 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
filterConditions = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const searchCondition = search
|
||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||
: "";
|
||||
const params: any[] = [companyCode];
|
||||
if (search) params.push(`%${search}%`);
|
||||
let paramIndex = 2;
|
||||
|
||||
// 검색 조건
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 추가 컬럼 SELECT
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
// 사전 필터 조건
|
||||
let filterWhere = "";
|
||||
if (filterConditions) {
|
||||
try {
|
||||
const filters = JSON.parse(filterConditions) as Array<{
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}>;
|
||||
for (const f of filters) {
|
||||
if (!f.column || !f.value) continue;
|
||||
if (f.operator === "equals") {
|
||||
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
} else if (f.operator === "contains") {
|
||||
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
|
||||
params.push(`%${f.value}%`);
|
||||
} else if (f.operator === "not_equals") {
|
||||
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
} catch { /* 파싱 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM ${tableName} i
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE i.company_code = $1
|
||||
${searchCondition}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||
${filterWhere}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
|
||||
ORDER BY i.created_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
@@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 등록 품목 관리 (item_routing_registered)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 화면별 등록된 품목 목록 조회
|
||||
*/
|
||||
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode } = req.params;
|
||||
const {
|
||||
tableName = "item_info",
|
||||
nameColumn = "item_name",
|
||||
codeColumn = "item_number",
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const params: any[] = [companyCode, screenCode];
|
||||
let paramIndex = 3;
|
||||
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
irr.id AS registered_id,
|
||||
irr.sort_order,
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM item_routing_registered irr
|
||||
JOIN ${tableName} i ON irr.item_id = i.id
|
||||
AND i.company_code = irr.company_code
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE irr.company_code = $1
|
||||
AND irr.screen_code = $2
|
||||
${searchCondition}
|
||||
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
|
||||
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("등록 품목 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 등록 (화면에 품목 추가)
|
||||
*/
|
||||
export async function registerItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, itemId, itemCode } = req.body;
|
||||
if (!screenCode || !itemId) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await getPool().query(query, [
|
||||
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
|
||||
}
|
||||
|
||||
logger.info("품목 등록", { companyCode, screenCode, itemId });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 품목 일괄 등록
|
||||
*/
|
||||
export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, items } = req.body;
|
||||
if (!screenCode || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
|
||||
}
|
||||
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *`,
|
||||
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||
);
|
||||
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
|
||||
return res.json({ success: true, data: inserted });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("품목 일괄 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 품목 제거
|
||||
*/
|
||||
export async function unregisterItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await getPool().query(
|
||||
`DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
logger.info("등록 품목 제거", { companyCode, id });
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("등록 품목 제거 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
233
backend-node/src/controllers/productionController.ts
Normal file
233
backend-node/src/controllers/productionController.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 생산계획 컨트롤러
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as productionService from "../services/productionPlanService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||
|
||||
export async function getOrderSummary(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { excludePlanned, itemCode, itemName } = req.query;
|
||||
|
||||
const data = await productionService.getOrderSummary(companyCode, {
|
||||
excludePlanned: excludePlanned === "true",
|
||||
itemCode: itemCode as string,
|
||||
itemName: itemName 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 getStockShortage(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await productionService.getStockShortage(companyCode);
|
||||
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) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const data = await productionService.getPlanById(companyCode, planId);
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" });
|
||||
}
|
||||
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 updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const updatedBy = req.user!.userId;
|
||||
|
||||
const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 수정 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 삭제 ───
|
||||
|
||||
export async function deletePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
|
||||
await productionService.deletePlan(companyCode, planId);
|
||||
return res.json({ success: true, message: "삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 삭제 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 자동 스케줄 미리보기 (실제 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) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const createdBy = req.user!.userId;
|
||||
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.generateSchedule(companyCode, items, options || {}, createdBy);
|
||||
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 mergeSchedules(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const mergedBy = req.user!.userId;
|
||||
const { schedule_ids, product_type } = req.body;
|
||||
|
||||
if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) {
|
||||
return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.mergeSchedules(
|
||||
companyCode,
|
||||
schedule_ids,
|
||||
product_type || "완제품",
|
||||
mergedBy
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("스케줄 병합 실패", { error: error.message });
|
||||
const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500;
|
||||
return res.status(status).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ───
|
||||
|
||||
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) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const createdBy = req.user!.userId;
|
||||
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.generateSemiSchedule(
|
||||
companyCode,
|
||||
plan_ids,
|
||||
options || {},
|
||||
createdBy
|
||||
);
|
||||
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 splitSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const splitBy = req.user!.userId;
|
||||
const planId = parseInt(req.params.id, 10);
|
||||
const { split_qty } = req.body;
|
||||
|
||||
if (!split_qty || split_qty <= 0) {
|
||||
return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("스케줄 분할 실패", { error: error.message });
|
||||
return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
||||
});
|
||||
});
|
||||
|
||||
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
|
||||
const v2RepeaterQuery = `
|
||||
SELECT DISTINCT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
comp->'overrides'->>'type' as component_type,
|
||||
comp->'overrides'->>'selectedTable' as sub_table,
|
||||
comp->'overrides'->>'foreignKey' as foreign_key,
|
||||
comp->'overrides'->>'parentTable' as parent_table
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
|
||||
jsonb_array_elements(slv2.layout_data->'components') as comp
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
AND comp->'overrides'->>'type' = 'v2-repeater'
|
||||
AND comp->'overrides'->>'selectedTable' IS NOT NULL
|
||||
`;
|
||||
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
|
||||
v2RepeaterResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
const subTable = row.sub_table;
|
||||
const foreignKey = row.foreign_key;
|
||||
if (!subTable || subTable === mainTable) return;
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
screenId,
|
||||
screenName: row.screen_name,
|
||||
mainTable: mainTable || '',
|
||||
subTables: [],
|
||||
};
|
||||
}
|
||||
const exists = screenSubTables[screenId].subTables.some(
|
||||
(st) => st.tableName === subTable
|
||||
);
|
||||
if (!exists) {
|
||||
screenSubTables[screenId].subTables.push({
|
||||
tableName: subTable,
|
||||
componentType: 'v2-repeater',
|
||||
relationType: 'rightPanelRelation',
|
||||
fieldMappings: foreignKey ? [{
|
||||
sourceField: 'id',
|
||||
targetField: foreignKey,
|
||||
sourceDisplayName: 'ID',
|
||||
targetDisplayName: foreignKey,
|
||||
}] : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info("v2-repeater 서브 테이블 추출 완료", {
|
||||
screenIds,
|
||||
v2RepeaterCount: v2RepeaterResult.rows.length,
|
||||
});
|
||||
|
||||
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
|
||||
const v2DetailTableQuery = `
|
||||
SELECT DISTINCT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
inner_comp->>'type' as component_type,
|
||||
inner_comp->'componentConfig'->>'detailTable' as sub_table,
|
||||
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
|
||||
jsonb_array_elements(slv2.layout_data->'components') as comp,
|
||||
jsonb_array_elements(
|
||||
COALESCE(
|
||||
comp->'overrides'->'rightPanel'->'components',
|
||||
comp->'overrides'->'leftPanel'->'components',
|
||||
'[]'::jsonb
|
||||
)
|
||||
) as inner_comp
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
|
||||
`;
|
||||
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
|
||||
v2DetailTableResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
const subTable = row.sub_table;
|
||||
const foreignKey = row.foreign_key;
|
||||
if (!subTable || subTable === mainTable) return;
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
screenId,
|
||||
screenName: row.screen_name,
|
||||
mainTable: mainTable || '',
|
||||
subTables: [],
|
||||
};
|
||||
}
|
||||
const exists = screenSubTables[screenId].subTables.some(
|
||||
(st) => st.tableName === subTable
|
||||
);
|
||||
if (!exists) {
|
||||
screenSubTables[screenId].subTables.push({
|
||||
tableName: subTable,
|
||||
componentType: row.component_type || 'v2-bom-tree',
|
||||
relationType: 'rightPanelRelation',
|
||||
fieldMappings: foreignKey ? [{
|
||||
sourceField: 'id',
|
||||
targetField: foreignKey,
|
||||
sourceDisplayName: 'ID',
|
||||
targetDisplayName: foreignKey,
|
||||
}] : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
|
||||
screenIds,
|
||||
v2DetailTableCount: v2DetailTableResult.rows.length,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 저장 테이블 정보 추출
|
||||
// ============================================================
|
||||
|
||||
@@ -104,6 +104,11 @@ interface TaskBody {
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
preCondition?: {
|
||||
column: string;
|
||||
expectedValue: string;
|
||||
failMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
@@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
condWhere += ` AND "${task.preCondition.column}" = $4`;
|
||||
condParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const condResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
|
||||
condParams,
|
||||
);
|
||||
if (task.preCondition && condResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (task.preCondition) {
|
||||
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
|
||||
taskId: task.id, preCondition: task.preCondition,
|
||||
});
|
||||
}
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
@@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
}
|
||||
|
||||
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) {
|
||||
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
}
|
||||
whereSql += ` AND "${task.preCondition.column}" = $4`;
|
||||
queryParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const updateResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
|
||||
queryParams,
|
||||
);
|
||||
if (task.preCondition && updateResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
@@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
if (error.isPreConditionFail) {
|
||||
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
errorCode: "PRE_CONDITION_FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
|
||||
15
backend-node/src/routes/popProductionRoutes.ts
Normal file
15
backend-node/src/routes/popProductionRoutes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
createWorkProcesses,
|
||||
controlTimer,
|
||||
} from "../controllers/popProductionController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.post("/create-work-processes", createWorkProcesses);
|
||||
router.post("/timer", controlTimer);
|
||||
|
||||
export default router;
|
||||
@@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||
// 전체 저장 (일괄)
|
||||
router.put("/save-all", ctrl.saveAll);
|
||||
|
||||
// 등록 품목 관리 (화면별 품목 목록)
|
||||
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
|
||||
router.post("/registered-items", ctrl.registerItem);
|
||||
router.post("/registered-items/batch", ctrl.registerItemsBatch);
|
||||
router.delete("/registered-items/:id", ctrl.unregisterItem);
|
||||
|
||||
export default router;
|
||||
|
||||
42
backend-node/src/routes/productionRoutes.ts
Normal file
42
backend-node/src/routes/productionRoutes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 생산계획 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as productionController from "../controllers/productionController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 수주 데이터 조회 (품목별 그룹핑)
|
||||
router.get("/order-summary", productionController.getOrderSummary);
|
||||
|
||||
// 안전재고 부족분 조회
|
||||
router.get("/stock-shortage", productionController.getStockShortage);
|
||||
|
||||
// 생산계획 CRUD
|
||||
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);
|
||||
|
||||
// 스케줄 분할
|
||||
router.post("/plan/:id/split", productionController.splitSchedule);
|
||||
|
||||
export default router;
|
||||
@@ -823,6 +823,76 @@ export class EntityJoinService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마 구분 다중값 해결 (겸직 부서 등)
|
||||
* entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환
|
||||
*/
|
||||
async resolveCommaValues(
|
||||
data: Record<string, any>[],
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<Record<string, any>[]> {
|
||||
if (!data.length || !joinConfigs.length) return data;
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
const sourceCol = config.sourceColumn;
|
||||
const displayCol = config.displayColumns?.[0] || config.displayColumn;
|
||||
if (!displayCol || displayCol === "none") continue;
|
||||
|
||||
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
|
||||
const labelCol = `${sourceCol}_label`;
|
||||
|
||||
const codesSet = new Set<string>();
|
||||
const rowsToResolve: number[] = [];
|
||||
|
||||
data.forEach((row, idx) => {
|
||||
const srcVal = row[sourceCol];
|
||||
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
|
||||
|
||||
const joinedVal = row[aliasCol] || row[labelCol];
|
||||
if (joinedVal && joinedVal !== "") return;
|
||||
|
||||
rowsToResolve.push(idx);
|
||||
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
|
||||
});
|
||||
|
||||
if (codesSet.size === 0) continue;
|
||||
|
||||
const codes = Array.from(codesSet);
|
||||
const refCol = config.referenceColumn || "id";
|
||||
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
|
||||
try {
|
||||
const result = await query<Record<string, any>>(
|
||||
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
|
||||
FROM ${config.referenceTable}
|
||||
WHERE "${refCol}"::TEXT IN (${placeholders})`,
|
||||
codes
|
||||
);
|
||||
|
||||
const labelMap = new Map<string, string>();
|
||||
result.forEach((r) => labelMap.set(r._key, r._label));
|
||||
|
||||
for (const idx of rowsToResolve) {
|
||||
const srcVal = data[idx][sourceCol] as string;
|
||||
const resolvedLabels = srcVal
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean)
|
||||
.map((code: string) => labelMap.get(code) || code)
|
||||
.join(", ");
|
||||
|
||||
data[idx][aliasCol] = resolvedLabels;
|
||||
data[idx][labelCol] = resolvedLabels;
|
||||
}
|
||||
|
||||
logger.info(`콤마 구분 entity 값 해결: ${sourceCol} → ${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
|
||||
} catch (err) {
|
||||
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinService = new EntityJoinService();
|
||||
|
||||
@@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
|
||||
result += val;
|
||||
if (idx < partValues.length - 1) {
|
||||
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
result += sep;
|
||||
if (val || !result.endsWith(sep)) {
|
||||
result += sep;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
@@ -74,16 +76,22 @@ class NumberingRuleService {
|
||||
*/
|
||||
private async buildPrefixKey(
|
||||
rule: NumberingRuleConfig,
|
||||
formData?: Record<string, any>
|
||||
formData?: Record<string, any>,
|
||||
manualValues?: string[]
|
||||
): Promise<string> {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const prefixParts: string[] = [];
|
||||
let manualIndex = 0;
|
||||
|
||||
for (const part of sortedParts) {
|
||||
if (part.partType === "sequence") continue;
|
||||
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
|
||||
const manualValue = manualValues?.[manualIndex] || "";
|
||||
manualIndex++;
|
||||
if (manualValue) {
|
||||
prefixParts.push(manualValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -227,6 +235,312 @@ class NumberingRuleService {
|
||||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운터를 특정 값 이상으로 동기화 (GREATEST 사용)
|
||||
* 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌
|
||||
*/
|
||||
private async setSequenceForPrefix(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
prefixKey: string,
|
||||
targetSequence: number
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
|
||||
last_allocated_at = NOW()
|
||||
RETURNING current_sequence`,
|
||||
[ruleId, companyCode, prefixKey, targetSequence]
|
||||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회
|
||||
* 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산
|
||||
*/
|
||||
private async getMaxSequenceFromTable(
|
||||
client: any,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
codePrefix: string,
|
||||
codeSuffix: string,
|
||||
seqLength: number,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
const colCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
const hasCompanyCode = colCheck.rows.length > 0;
|
||||
|
||||
// 대상 컬럼 존재 여부 확인
|
||||
const targetColCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = $2`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
if (targetColCheck.rows.length === 0) {
|
||||
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
|
||||
const prefixLen = codePrefix.length;
|
||||
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
|
||||
|
||||
// LIKE 패턴: prefix + N자리 숫자 + suffix
|
||||
const likePattern = codePrefix + "%" + codeSuffix;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode && companyCode !== "*") {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND company_code = $4
|
||||
AND LENGTH("${columnName}") = $5
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
|
||||
} else {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND LENGTH("${columnName}") = $4
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
|
||||
}
|
||||
|
||||
const result = await client.query(sql, params);
|
||||
const maxSeq = result.rows[0]?.max_seq ?? 0;
|
||||
|
||||
logger.info("getMaxSequenceFromTable 결과", {
|
||||
tableName, columnName, codePrefix, codeSuffix,
|
||||
seqLength, companyCode, maxSeq,
|
||||
});
|
||||
|
||||
return maxSeq;
|
||||
} catch (error: any) {
|
||||
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
|
||||
tableName, columnName, error: error.message,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산
|
||||
* allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출
|
||||
*/
|
||||
private buildCodePrefixSuffix(
|
||||
partValues: string[],
|
||||
sortedParts: any[],
|
||||
globalSeparator: string
|
||||
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
|
||||
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
|
||||
if (seqIndex === -1) return null;
|
||||
|
||||
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
|
||||
|
||||
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
|
||||
let prefix = "";
|
||||
for (let i = 0; i < seqIndex; i++) {
|
||||
prefix += partValues[i];
|
||||
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
prefix += sep;
|
||||
}
|
||||
|
||||
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
|
||||
let suffix = "";
|
||||
for (let i = seqIndex + 1; i < partValues.length; i++) {
|
||||
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
if (i === seqIndex + 1) {
|
||||
// sequence 파트 바로 뒤 구분자
|
||||
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += seqSep;
|
||||
}
|
||||
suffix += partValues[i];
|
||||
if (i < partValues.length - 1) {
|
||||
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += nextSep;
|
||||
}
|
||||
}
|
||||
|
||||
return { prefix, suffix, seqIndex, seqLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용
|
||||
* sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리)
|
||||
*/
|
||||
private async computeNonSequenceValues(
|
||||
rule: NumberingRuleConfig,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string[]> {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
return Promise.all(sortedParts.map(async (part: any) => {
|
||||
if (part.partType === "sequence") return "";
|
||||
if (part.generationMethod === "manual") return "";
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date": {
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
if (!categoryKey || !formData) return "";
|
||||
|
||||
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
|
||||
const selectedValue = formData[colName];
|
||||
if (!selectedValue) return "";
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct, cc, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
|
||||
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return mapping?.format || "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고,
|
||||
* 카운터와 비교하여 더 높은 값 + 1을 반환
|
||||
*/
|
||||
private async resolveNextSequence(
|
||||
client: any,
|
||||
rule: NumberingRuleConfig,
|
||||
companyCode: string,
|
||||
ruleId: string,
|
||||
prefixKey: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<number> {
|
||||
// 1. 현재 저장된 카운터 조회
|
||||
const currentCounter = await this.getSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
|
||||
let baseSequence = currentCounter;
|
||||
|
||||
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
client, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSequence) {
|
||||
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
|
||||
ruleId, companyCode, currentCounter, maxFromTable,
|
||||
});
|
||||
baseSequence = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 다음 시퀀스 = base + 1
|
||||
const nextSequence = baseSequence + 1;
|
||||
|
||||
// 4. 카운터를 동기화 (GREATEST 사용)
|
||||
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
|
||||
|
||||
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode, nextSequence]
|
||||
);
|
||||
|
||||
logger.info("resolveNextSequence 완료", {
|
||||
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
|
||||
});
|
||||
|
||||
return nextSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
@@ -1078,22 +1392,59 @@ class NumberingRuleService {
|
||||
* @param ruleId 채번 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
|
||||
*/
|
||||
async previewCode(
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
formData?: Record<string, any>,
|
||||
manualInputValue?: string
|
||||
): Promise<string> {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번 조회
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const pool = getPool();
|
||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
|
||||
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq,
|
||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||
const pool = getPool();
|
||||
const currentSeq = skipSequenceLookup
|
||||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
let baseSeq = currentSeq;
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
pool, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSeq) {
|
||||
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
|
||||
ruleId, companyCode, currentSeq, maxFromTable,
|
||||
});
|
||||
baseSeq = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("미리보기: 순번 조회 완료", {
|
||||
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
@@ -1108,7 +1459,8 @@ class NumberingRuleService {
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = currentSeq + 1;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = baseSeq + startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
@@ -1150,110 +1502,8 @@ class NumberingRuleService {
|
||||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
// 카테고리 기반 코드 생성
|
||||
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
logger.warn("카테고리 키 또는 폼 데이터 없음", {
|
||||
categoryKey,
|
||||
hasFormData: !!formData,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
// 폼 데이터에서 해당 컬럼의 값 가져오기
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
logger.info("카테고리 파트 처리", {
|
||||
categoryKey,
|
||||
columnName,
|
||||
selectedValue,
|
||||
formDataKeys: Object.keys(formData),
|
||||
mappingsCount: categoryMappings.length,
|
||||
});
|
||||
|
||||
if (!selectedValue) {
|
||||
logger.warn("카테고리 값이 선택되지 않음", {
|
||||
columnName,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭 (폴백)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
||||
valueCode: selectedValueStr,
|
||||
resolvedId,
|
||||
resolvedLabel,
|
||||
format: mapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (lookupError: any) {
|
||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel,
|
||||
});
|
||||
return mapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
case "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
@@ -1302,154 +1552,46 @@ class NumberingRuleService {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
|
||||
let allocatedSequence = 0;
|
||||
if (hasSequence) {
|
||||
allocatedSequence = await this.incrementSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
// 호환성을 위해 기존 current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: prefix_key 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence,
|
||||
});
|
||||
|
||||
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
||||
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
|
||||
const manualParts = rule.parts.filter(
|
||||
(p: any) => p.generationMethod === "manual"
|
||||
);
|
||||
let extractedManualValues: string[] = [];
|
||||
|
||||
if (manualParts.length > 0 && userInputCode) {
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return "____";
|
||||
}
|
||||
const autoConfig = part.autoConfig || {};
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return "X".repeat(length);
|
||||
}
|
||||
case "text":
|
||||
return autoConfig.textValue || "";
|
||||
case "date":
|
||||
return "DATEPART";
|
||||
case "category": {
|
||||
const catKey2 = autoConfig.categoryKey;
|
||||
const catMappings2 = autoConfig.categoryMappings || [];
|
||||
extractedManualValues = await this.extractManualValuesFromInput(
|
||||
rule, userInputCode, formData
|
||||
);
|
||||
|
||||
if (!catKey2 || !formData) {
|
||||
return "CATEGORY";
|
||||
}
|
||||
|
||||
const colName2 = catKey2.includes(".")
|
||||
? catKey2.split(".")[1]
|
||||
: catKey2;
|
||||
const selVal2 = formData[colName2];
|
||||
|
||||
if (!selVal2) {
|
||||
return "CATEGORY";
|
||||
}
|
||||
|
||||
const selValStr2 = String(selVal2);
|
||||
let catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selValStr2) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
|
||||
if (m.categoryValueLabel === selValStr2) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!catMapping2) {
|
||||
try {
|
||||
const pool2 = getPool();
|
||||
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
|
||||
const cvr2 = await pool2.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct2, cc2, selValStr2]
|
||||
);
|
||||
if (cvr2.rows.length > 0) {
|
||||
const rid2 = cvr2.rows[0].value_id;
|
||||
const rlabel2 = cvr2.rows[0].value_label;
|
||||
catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid2)) return true;
|
||||
if (m.categoryValueLabel === rlabel2) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
case "reference": {
|
||||
const refCol2 = autoConfig.referenceColumnName;
|
||||
if (refCol2 && formData && formData[refCol2]) {
|
||||
return String(formData[refCol2]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
|
||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||
|
||||
const templateParts = previewTemplate.split("____");
|
||||
if (templateParts.length > 1) {
|
||||
let remainingCode = userInputCode;
|
||||
for (let i = 0; i < templateParts.length - 1; i++) {
|
||||
const prefix = templateParts[i];
|
||||
const suffix = templateParts[i + 1];
|
||||
|
||||
if (prefix && remainingCode.startsWith(prefix)) {
|
||||
remainingCode = remainingCode.slice(prefix.length);
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
||||
const manualEndIndex = suffixStart
|
||||
? remainingCode.indexOf(suffixStart)
|
||||
: remainingCode.length;
|
||||
if (manualEndIndex > 0) {
|
||||
extractedManualValues.push(
|
||||
remainingCode.slice(0, manualEndIndex)
|
||||
);
|
||||
remainingCode = remainingCode.slice(manualEndIndex);
|
||||
}
|
||||
} else {
|
||||
extractedManualValues.push(remainingCode);
|
||||
}
|
||||
}
|
||||
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
|
||||
if (extractedManualValues.length === 0 && manualParts.length === 1) {
|
||||
extractedManualValues = [userInputCode];
|
||||
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
|
||||
// 2단계: prefix_key 빌드 (수동 값 포함)
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
|
||||
let allocatedSequence = 0;
|
||||
if (hasSequence) {
|
||||
allocatedSequence = await this.resolveNextSequence(
|
||||
client, rule, companyCode, ruleId, prefixKey, formData
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence, extractedManualValues,
|
||||
});
|
||||
|
||||
let manualPartIndex = 0;
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
const manualValue =
|
||||
extractedManualValues[manualPartIndex] ||
|
||||
part.manualConfig?.value ||
|
||||
"";
|
||||
const manualValue = extractedManualValues[manualPartIndex] || "";
|
||||
manualPartIndex++;
|
||||
return manualValue;
|
||||
}
|
||||
@@ -1459,7 +1601,9 @@ class NumberingRuleService {
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(allocatedSequence).padStart(length, "0");
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const actualSequence = allocatedSequence + startFrom - 1;
|
||||
return String(actualSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
@@ -1496,65 +1640,14 @@ class NumberingRuleService {
|
||||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
if (!selectedValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvr3 = await pool3.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct3, cc3, selectedValueStr]
|
||||
);
|
||||
if (cvr3.rows.length > 0) {
|
||||
const rid3 = cvr3.rows[0].value_id;
|
||||
const rlabel3 = cvr3.rows[0].value_label;
|
||||
allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
case "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1593,6 +1686,139 @@ class NumberingRuleService {
|
||||
return this.allocateCode(ruleId, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 입력 코드에서 수동 파트 값을 추출
|
||||
* 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리
|
||||
*/
|
||||
private async extractManualValuesFromInput(
|
||||
rule: NumberingRuleConfig,
|
||||
userInputCode: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string[]> {
|
||||
const extractedValues: string[] = [];
|
||||
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return "____";
|
||||
}
|
||||
const autoConfig = part.autoConfig || {};
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return "X".repeat(length);
|
||||
}
|
||||
case "text":
|
||||
return autoConfig.textValue || "";
|
||||
case "date":
|
||||
return "DATEPART";
|
||||
case "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
|
||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||
|
||||
const templateParts = previewTemplate.split("____");
|
||||
if (templateParts.length > 1) {
|
||||
let remainingCode = userInputCode;
|
||||
for (let i = 0; i < templateParts.length - 1; i++) {
|
||||
const prefix = templateParts[i];
|
||||
const suffix = templateParts[i + 1];
|
||||
|
||||
if (prefix && remainingCode.startsWith(prefix)) {
|
||||
remainingCode = remainingCode.slice(prefix.length);
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
||||
const manualEndIndex = suffixStart
|
||||
? remainingCode.indexOf(suffixStart)
|
||||
: remainingCode.length;
|
||||
if (manualEndIndex > 0) {
|
||||
extractedValues.push(
|
||||
remainingCode.slice(0, manualEndIndex)
|
||||
);
|
||||
remainingCode = remainingCode.slice(manualEndIndex);
|
||||
}
|
||||
} else {
|
||||
extractedValues.push(remainingCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
|
||||
);
|
||||
|
||||
return extractedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 매핑에서 format 값을 해석
|
||||
* categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환
|
||||
*/
|
||||
private async resolveCategoryFormat(
|
||||
autoConfig: Record<string, any>,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) return "";
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
if (!selectedValue) return "";
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [tableName, colName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const result = await pool.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[tableName, colName, selectedValueStr]
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
const resolvedId = result.rows[0].value_id;
|
||||
const resolvedLabel = result.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return mapping?.format || "";
|
||||
}
|
||||
|
||||
private formatDate(date: Date, format: string): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
879
backend-node/src/services/productionPlanService.ts
Normal file
879
backend-node/src/services/productionPlanService.ts
Normal file
@@ -0,0 +1,879 @@
|
||||
/**
|
||||
* 생산계획 서비스
|
||||
* - 수주 데이터 조회 (품목별 그룹핑)
|
||||
* - 안전재고 부족분 조회
|
||||
* - 자동 스케줄 생성
|
||||
* - 스케줄 병합
|
||||
* - 반제품 계획 자동 생성
|
||||
* - 스케줄 분할
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 스케줄 미리보기 (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[],
|
||||
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);
|
||||
}
|
||||
|
||||
// 계획번호 생성 (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
|
||||
) {
|
||||
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(
|
||||
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 semiDueDate = plan.start_date;
|
||||
const semiEndDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -3588,12 +3588,15 @@ export class TableManagementService {
|
||||
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
||||
);
|
||||
|
||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||
let data = Array.isArray(dataResult) ? dataResult : [];
|
||||
const total =
|
||||
Array.isArray(countResult) && countResult.length > 0
|
||||
? Number((countResult[0] as any).total)
|
||||
: 0;
|
||||
|
||||
// 콤마 구분 다중값 후처리 (겸직 부서 등)
|
||||
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
|
||||
|
||||
const queryTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user