Enhance Item Inspection and Outbound Functionality

- Added `apply_process_name` to the item inspection controller, allowing for better clarity in process identification by joining with the `process_mng` table.
- Updated the outbound controller to include additional delivery details, such as `delivery_destination_name` and `customer_name`, with fallback logic for improved data accuracy.
- Enhanced the query logic to ensure proper handling of delivery addresses and customer information, improving the overall data retrieval process.

(TASK: ERP-XXX)
This commit is contained in:
kjs
2026-05-22 09:59:20 +09:00
parent c8994b49fc
commit 9f9be20e34
28 changed files with 781 additions and 152 deletions

View File

@@ -18,6 +18,7 @@ interface InspectionRow {
inspection_item_name: string | null;
inspection_method: string | null;
apply_process: string | null;
apply_process_name: string | null;
classification: string | null;
pass_criteria: string | null;
upper_limit: string | null;
@@ -96,16 +97,25 @@ export async function getGroupedList(req: AuthenticatedRequest, res: Response) {
}
// 3) 페이지 item_code들의 모든 검사항목 row
// 적용공정(apply_process)은 공정 코드(P001 등)이므로 process_mng JOIN으로 공정명 해석.
// 공정 매핑이 깨졌거나 코드가 없으면 apply_process_name 은 NULL → 프론트에서 코드 fallback.
const detailQuery = `
SELECT
id, item_code, item_name, inspection_type, inspection_standard, inspection_standard_id,
inspection_item_name, inspection_method, apply_process, classification,
pass_criteria, upper_limit, lower_limit, is_required, is_active, manager_id, memo,
sort_order, change_record, created_date, updated_date
FROM item_inspection_info
WHERE company_code = $1
AND item_code = ANY($2::text[])
ORDER BY item_code, sort_order, created_date;
iii.id, iii.item_code, iii.item_name, iii.inspection_type, iii.inspection_standard,
iii.inspection_standard_id, iii.inspection_item_name, iii.inspection_method,
iii.apply_process,
pm.process_name AS apply_process_name,
iii.classification,
iii.pass_criteria, iii.upper_limit, iii.lower_limit, iii.is_required, iii.is_active,
iii.manager_id, iii.memo, iii.sort_order, iii.change_record,
iii.created_date, iii.updated_date
FROM item_inspection_info iii
LEFT JOIN process_mng pm
ON pm.process_code = iii.apply_process
AND pm.company_code = iii.company_code
WHERE iii.company_code = $1
AND iii.item_code = ANY($2::text[])
ORDER BY iii.item_code, iii.sort_order, iii.created_date;
`;
const detailRes = await pool.query(detailQuery, [companyCode, itemCodes]);
const rowsByItem: Record<string, InspectionRow[]> = {};

View File

@@ -75,11 +75,45 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
const query = `
SELECT
om.*,
wh.warehouse_name
wh.warehouse_name,
-- 납품처 (TASK:ERP-097): 출고 자체 납품처 정보 우선,
-- 없으면 출처 출하지시(shipment_instruction_detail → shipment_instruction)에서 폴백
COALESCE(
NULLIF(om.delivery_destination, ''),
NULLIF(om.delivery_address, ''),
NULLIF(sid.delivery_address, ''),
NULLIF(si.delivery_address, ''),
''
) AS delivery_destination_name,
-- 거래처명 (TASK:ERP-098 항목20): 출고 행 customer_name 우선,
-- 없으면 customer_mng JOIN, 매핑 깨지면 코드 fallback
COALESCE(
NULLIF(om.customer_name, ''),
NULLIF(c.customer_name, ''),
NULLIF(om.customer_code, ''),
''
) AS customer_name,
-- 품목 규격 (TASK:ERP-098 항목19): 유리업종 가로/세로/두께 (item_info JOIN)
COALESCE(ii.width::text, '') AS width,
COALESCE(ii.height::text, '') AS height,
COALESCE(ii.thickness::text, '') AS thickness
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om.warehouse_code = wh.warehouse_code
AND om.company_code = wh.company_code
LEFT JOIN shipment_instruction_detail sid
ON om.source_table = 'shipment_instruction_detail'
AND om.source_id = sid.id::text
AND om.company_code = sid.company_code
LEFT JOIN shipment_instruction si
ON sid.instruction_id = si.id
AND sid.company_code = si.company_code
LEFT JOIN customer_mng c
ON om.customer_code = c.customer_code
AND om.company_code = c.company_code
LEFT JOIN item_info ii
ON om.item_code = ii.item_number
AND om.company_code = ii.company_code
${whereClause}
ORDER BY om.created_date DESC
`;
@@ -581,7 +615,17 @@ export async function getShipmentInstructions(
let paramIdx = 2;
if (customer) {
conditions.push(`si.partner_id = $${paramIdx}`);
// 출하지시(shipment_instruction.partner_id)에는 거래처코드 또는 거래처명이
// 혼재 저장될 수 있음(출하지시 등록 화면이 partner_code 미확보 시 거래처명을
// fallback 저장). POP 출고는 거래처코드로 조회하므로 코드/명 양쪽 + 거래처
// 마스터의 코드/명 모두로 매칭해 누락을 방지한다.
conditions.push(
`(
si.partner_id = $${paramIdx}
OR c.customer_code = $${paramIdx}
OR c.customer_name = $${paramIdx}
)`,
);
params.push(customer);
paramIdx++;
}
@@ -632,7 +676,7 @@ export async function getShipmentInstructions(
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
LEFT JOIN customer_mng c
ON si.partner_id = c.customer_code
ON (si.partner_id = c.customer_code OR si.partner_id = c.customer_name)
AND si.company_code = c.company_code
LEFT JOIN sales_order_detail sod
ON sod.id::text = sid.detail_id::text
@@ -641,7 +685,7 @@ export async function getShipmentInstructions(
ON ii.item_number = sid.item_code
AND ii.company_code = sid.company_code
LEFT JOIN customer_item_mapping cim
ON cim.customer_id = si.partner_id
ON cim.customer_id = COALESCE(c.customer_code, si.partner_id)
AND cim.company_code = sid.company_code
AND (cim.item_id = sid.item_code OR cim.item_id = ii.id::text)
WHERE ${conditions.join(" AND ")}

View File

@@ -237,8 +237,8 @@ export async function createPkgUnitItem(
}
const result = await pool.query(
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
VALUES ($1,$2,$3,$4,$5)
`INSERT INTO pkg_unit_item (id, company_code, pkg_code, item_number, pkg_qty, writer, created_date)
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5, NOW())
RETURNING *`,
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
);
@@ -513,8 +513,8 @@ export async function createLoadingUnitPkg(
}
const result = await pool.query(
`INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
VALUES ($1,$2,$3,$4,$5,$6)
`INSERT INTO loading_unit_pkg (id, company_code, loading_code, pkg_code, max_load_qty, load_method, writer, created_date)
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5,$6, NOW())
RETURNING *`,
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
);

View File

@@ -3631,6 +3631,11 @@ export const getChecklistItems = async (
.json({ success: false, message: "processId 필수" });
}
// 검사기준 마스터(inspection_standard) JOIN — 판단기준(judgment_criteria) 전달용.
// - 체크리스트 row 의 inspection_code 는 비어있는 경우가 많아(템플릿 미연결)
// 1차로 검사항목명(detail_content/item_title) ↔ inspection_standard.inspection_item 로 매칭,
// 2차로 inspection_code 매칭을 fallback 으로 둔다.
// - 매칭된 검사기준의 unit/lower_limit/upper_limit 도 보조 노출(체크리스트 row 자체값 우선).
const result = await pool.query(
`SELECT
pwr.id,
@@ -3647,7 +3652,7 @@ export const getChecklistItems = async (
pwr.is_required,
pwr.inspection_code,
pwr.inspection_method,
pwr.unit,
COALESCE(NULLIF(pwr.unit, ''), ist.unit) AS unit,
pwr.lower_limit,
pwr.upper_limit,
pwr.input_type,
@@ -3665,11 +3670,28 @@ export const getChecklistItems = async (
pwr.group_paused_at,
pwr.group_total_paused_time,
pwr.group_completed_at,
ist.judgment_criteria
ist.judgment_criteria,
ist.selection_options,
ist.inspection_code AS matched_inspection_code
FROM process_work_result pwr
LEFT JOIN inspection_standard ist
ON pwr.inspection_code = ist.inspection_code
AND pwr.company_code = ist.company_code
LEFT JOIN LATERAL (
SELECT s.judgment_criteria, s.unit, s.inspection_code, s.selection_options
FROM inspection_standard s
WHERE s.company_code = pwr.company_code
AND (
(NULLIF(pwr.inspection_code, '') IS NOT NULL
AND s.inspection_code = pwr.inspection_code)
-- detail_content 에는 UI 구분자(" |") 가 함께 저장된 경우가 있어 양끝 공백/'|' 제거 후 비교
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
= TRIM(BOTH ' |' FROM COALESCE(pwr.detail_content, ''))
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
= TRIM(BOTH ' |' FROM COALESCE(pwr.item_title, ''))
)
-- inspection_code 정확매칭을 최우선, 그 다음 항목명 매칭
ORDER BY CASE WHEN NULLIF(pwr.inspection_code, '') IS NOT NULL
AND s.inspection_code = pwr.inspection_code THEN 0 ELSE 1 END
LIMIT 1
) ist ON true
WHERE pwr.work_order_process_id = $1
AND pwr.company_code = $2
ORDER BY

View File

@@ -59,6 +59,9 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
SELECT
si.*,
COALESCE(c.customer_name, '') AS customer_name,
-- 납품처 (TASK:ERP-097): 출하지시 헤더의 납품주소 우선,
-- 없으면 거래처 기본 납품처(delivery_destination)로 폴백
COALESCE(NULLIF(si.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name,
COALESCE(
json_agg(
json_build_object(
@@ -91,8 +94,15 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
WHERE item_number = sid.item_code AND company_code = si.company_code
LIMIT 1
) i ON true
LEFT JOIN LATERAL (
SELECT destination_name FROM delivery_destination
WHERE customer_code = si.partner_id AND company_code = si.company_code
ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END,
created_date ASC NULLS LAST
LIMIT 1
) dd ON true
${where}
GROUP BY si.id, c.customer_name
GROUP BY si.id, c.customer_name, dd.destination_name
ORDER BY si.created_date DESC
`;

View File

@@ -257,7 +257,10 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty,
COALESCE(NULLIF(d.width, ''), i.width::text, '') AS width,
COALESCE(NULLIF(d.height, ''), i.height::text, '') AS height,
COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness
COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness,
-- 납품처 (TASK:ERP-097): 수주상세/마스터의 납품장소 텍스트 우선,
-- 없으면 거래처 기본 납품처(delivery_destination)로 폴백
COALESCE(NULLIF(d.delivery_location, ''), NULLIF(m.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name
FROM shipment_plan sp
LEFT JOIN sales_order_mng m
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
@@ -274,6 +277,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
LEFT JOIN customer_mng c
ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code
AND sp.company_code = c.company_code
LEFT JOIN LATERAL (
SELECT destination_name FROM delivery_destination
WHERE customer_code = COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, ''))
AND company_code = sp.company_code
ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END,
created_date ASC NULLS LAST
LIMIT 1
) dd ON true
${whereClause}
ORDER BY sp.created_date DESC
`;

View File

@@ -388,10 +388,36 @@ export async function save(req: AuthenticatedRequest, res: Response) {
wiId = insertRes.rows[0].id;
}
// 품목별 기본 라우팅 버전 자동 해석 (TASK:ERP-node-104)
// 절단계획 적용 등으로 item.routing 이 비어 들어오는 경로를 위해,
// 해당 품목의 활성(기본) 라우팅 버전을 조회해 자동 세팅한다.
// - is_default=true 우선, 없으면 최신(created_date DESC) 1건
// - 품목에 라우팅 버전이 0건이면 빈값 유지(작업지시 생성은 차단하지 않음)
const routingCache = new Map<string, string | null>();
const resolveDefaultRouting = async (itemCode: string): Promise<string | null> => {
const key = String(itemCode || "").trim();
if (!key) return null;
if (routingCache.has(key)) return routingCache.get(key) || null;
const rv = await client.query(
`SELECT id FROM item_routing_version
WHERE item_code = $1 AND company_code = $2
ORDER BY COALESCE(is_default, false) DESC, created_date DESC
LIMIT 1`,
[key, companyCode]
);
const resolved = rv.rowCount && rv.rowCount > 0 ? rv.rows[0].id : null;
routingCache.set(key, resolved);
return resolved;
};
let totalQty = 0;
let firstRouting: string | null = null;
for (const item of items) {
const itemRouting = item.routing || null;
// 프론트가 명시한 routing 우선, 없으면 품목 기본 라우팅 버전 자동 조회
let itemRouting: string | null = item.routing || null;
if (!itemRouting) {
itemRouting = await resolveDefaultRouting(item.itemNumber || item.itemCode || item.partCode || "");
}
if (!firstRouting && itemRouting) firstRouting = itemRouting;
totalQty += Number(item.qty || 0);
await client.query(

View File

@@ -12,9 +12,15 @@ import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 근무일 계산 헬퍼 (토/일 제외) ───
//
// ⚠️ 모든 날짜 연산은 UTC 기준으로 통일한다. (TASK:ERP-node-107)
// `new Date("2026-05-21")` 는 UTC 자정으로 파싱되는데,
// `new Date(); setHours(0,0,0,0)` 는 로컬(KST) 자정 → UTC 로는 전날이 됨.
// 둘을 섞으면 `.toISOString()` 직렬화 시 스케줄 날짜가 하루 과거로 밀린다.
// → getUTC*/setUTC* 와 todayUTC() 로 기준을 일치시켜 드리프트 제거.
function isWeekend(date: Date): boolean {
const day = date.getDay();
const day = date.getUTCDay();
return day === 0 || day === 6;
}
@@ -22,7 +28,7 @@ function isWeekend(date: Date): boolean {
function skipWeekend(date: Date, direction: "forward" | "backward"): Date {
const d = new Date(date);
while (isWeekend(d)) {
d.setDate(d.getDate() + (direction === "forward" ? 1 : -1));
d.setUTCDate(d.getUTCDate() + (direction === "forward" ? 1 : -1));
}
return d;
}
@@ -34,12 +40,79 @@ function addWorkingDays(date: Date, workingDays: number): Date {
const step = workingDays > 0 ? 1 : -1;
let remaining = Math.abs(workingDays);
while (remaining > 0) {
d.setDate(d.getDate() + step);
d.setUTCDate(d.getUTCDate() + step);
if (!isWeekend(d)) remaining--;
}
return d;
}
// 오늘(UTC 자정). 스케줄 입력 날짜(new Date("YYYY-MM-DD"))와 동일 기준.
function todayUTC(): Date {
const n = new Date();
return new Date(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate()));
}
// earliest_due_date(문자열/null) → 유효한 Date 또는 null
function parseDueDate(raw: unknown): Date | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s) return null;
// "2026-05-21" 또는 "2026-05-21T..." 형태 모두 앞 10자리만 사용 → UTC 자정 파싱
const d = new Date(s.slice(0, 10));
return isNaN(d.getTime()) ? null : d;
}
/**
* 단일 품목 스케줄 날짜 산출 (preview/generate 공통).
* - earliest_due_date 가 유효하지 않으면 오늘을 납기로 간주.
* - 생산일수: lead_time>0 이면 lead_time, 아니면 ceil(qty/dailyCapacity) (최소 1일).
* - 시작일이 과거면 오늘부터 재배치(근무일 수 유지) — 현행 정책 유지(납기 초과 시 경고만).
*/
function computeScheduleDates(
requiredQty: number,
earliestDueDate: unknown,
itemLeadTime: number,
dailyCapacity: number,
safetyLeadTime: number
): { startDate: Date; endDate: Date; productionDays: number; dueExceeded: boolean } {
const productionDays =
itemLeadTime > 0 ? itemLeadTime : Math.max(1, Math.ceil(requiredQty / dailyCapacity));
// 납기일 미설정 시 오늘을 납기로 간주 (스케줄은 산출하되 dueExceeded 로 경고)
const parsedDue = parseDueDate(earliestDueDate);
const dueDate = parsedDue || todayUTC();
let endDate: Date;
let startDate: Date;
if (itemLeadTime > 0) {
// 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전
endDate = skipWeekend(new Date(dueDate), "backward");
startDate = addWorkingDays(endDate, -(productionDays - 1));
} else {
// 리드타임 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일)
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
startDate = addWorkingDays(endDate, -(productionDays - 1));
}
// 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지
const today = todayUTC();
if (startDate < today) {
startDate = skipWeekend(today, "forward");
endDate = addWorkingDays(startDate, productionDays - 1);
}
// 납기 초과 판정: 종료일이 납기일(주말 보정 후)보다 늦으면 경고
const dueDeadline = skipWeekend(new Date(dueDate), "backward");
const dueExceeded = endDate.getTime() > dueDeadline.getTime();
return { startDate, endDate, productionDays, dueExceeded };
}
function fmtDate(d: Date): string {
return d.toISOString().split("T")[0];
}
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
export async function getOrderSummary(
@@ -570,11 +643,17 @@ export async function previewSchedule(
) {
const pool = getPool();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
// safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1
const safetyLeadTime =
options.safety_lead_time != null ? options.safety_lead_time : 1;
const previews: any[] = [];
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
// 스케줄 대상에서 제외된 품목 (수량 0 / 납기 미설정 등) — 화면 안내용
const skipped: any[] = [];
// 납기 초과 경고가 붙은 품목
const warnings: any[] = [];
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) {
@@ -610,31 +689,25 @@ export async function previewSchedule(
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
const dueDate = new Date(item.earliest_due_date);
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
let endDate: Date;
let startDate: Date;
if (itemLeadTime > 0) {
// 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전
endDate = skipWeekend(new Date(dueDate), "backward");
startDate = addWorkingDays(endDate, -(productionDays - 1));
} else {
// 리드타임이 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일)
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
startDate = addWorkingDays(endDate, -(productionDays - 1));
// 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등)
if (!(requiredQty > 0)) {
skipped.push({
item_code: item.item_code,
item_name: item.item_name,
reason: "계획 수량이 0 이하",
});
continue;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
// 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지
startDate = skipWeekend(today, "forward");
endDate = addWorkingDays(startDate, productionDays - 1);
}
const hasValidDue = parseDueDate(item.earliest_due_date) != null;
const { startDate, endDate, productionDays, dueExceeded } = computeScheduleDates(
requiredQty,
item.earliest_due_date,
itemLeadTime,
dailyCapacity,
safetyLeadTime
);
// 해당 품목의 수주 건수 확인
const orderCountResult = await pool.query(
@@ -644,18 +717,34 @@ export async function previewSchedule(
);
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
// 경고 사유 수집 (스케줄은 산출 — 현행 정책: 경고만)
const itemWarnings: string[] = [];
if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)");
if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과");
if (itemWarnings.length > 0) {
warnings.push({
item_code: item.item_code,
item_name: item.item_name,
messages: itemWarnings,
});
}
previews.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: requiredQty,
plan_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,
start_date: fmtDate(startDate),
end_date: fmtDate(endDate),
due_date: hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null,
lead_time: itemLeadTime,
order_count: orderCount,
due_exceeded: dueExceeded,
warning: itemWarnings.length > 0 ? itemWarnings.join(", ") : null,
status: "planned",
});
}
@@ -665,10 +754,12 @@ export async function previewSchedule(
new_count: previews.length,
kept_count: keptSchedules.length,
deleted_count: deletedSchedules.length,
skipped_count: skipped.length,
warning_count: warnings.length,
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, schedules: previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules, skipped, warnings };
}
export async function generateSchedule(
@@ -680,7 +771,9 @@ export async function generateSchedule(
const pool = getPool();
const client = await pool.connect();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
// safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1
const safetyLeadTime =
options.safety_lead_time != null ? options.safety_lead_time : 1;
try {
await client.query("BEGIN");
@@ -688,6 +781,8 @@ export async function generateSchedule(
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
const skipped: any[] = [];
const warnings: any[] = [];
const deletedQtyByItem = new Map<string, number>();
// 같은 item_code에 대한 삭제는 한 번만 수행
@@ -734,28 +829,37 @@ export async function generateSchedule(
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
const dueDate = new Date(item.earliest_due_date);
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
let endDate: Date;
let startDate: Date;
if (itemLeadTime > 0) {
endDate = skipWeekend(new Date(dueDate), "backward");
startDate = addWorkingDays(endDate, -(productionDays - 1));
} else {
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
startDate = addWorkingDays(endDate, -(productionDays - 1));
// 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등)
if (!(requiredQty > 0)) {
skipped.push({
item_code: item.item_code,
item_name: item.item_name,
reason: "계획 수량이 0 이하",
});
continue;
}
// 시작일이 오늘보다 이전이면 오늘(주말이면 다음 평일)로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
startDate = skipWeekend(today, "forward");
endDate = addWorkingDays(startDate, productionDays - 1);
const hasValidDue = parseDueDate(item.earliest_due_date) != null;
// 리드타임 기반 날짜 계산 (preview 와 동일 로직 — computeScheduleDates 공유)
const { startDate, endDate, dueExceeded } = computeScheduleDates(
requiredQty,
item.earliest_due_date,
itemLeadTime,
dailyCapacity,
safetyLeadTime
);
const itemWarnings: string[] = [];
if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)");
if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과");
if (itemWarnings.length > 0) {
warnings.push({
item_code: item.item_code,
item_name: item.item_name,
messages: itemWarnings,
});
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
@@ -784,16 +888,17 @@ export async function generateSchedule(
[
companyCode, planNo, item.item_code, item.item_name,
productType, requiredQty,
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
item.earliest_due_date,
fmtDate(startDate),
fmtDate(endDate),
// 납기 미설정/빈값이면 NULL 저장 ('' 를 date 컬럼에 넣으면 에러)
hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null,
item.hourly_capacity || 100,
dailyCapacity,
item.lead_time || 1,
createdBy,
]
);
newSchedules.push(insertResult.rows[0]);
newSchedules.push({ ...insertResult.rows[0], due_exceeded: dueExceeded });
}
await client.query("COMMIT");
@@ -803,10 +908,12 @@ export async function generateSchedule(
new_count: newSchedules.length,
kept_count: keptCount,
deleted_count: deletedCount,
skipped_count: skipped.length,
warning_count: warnings.length,
};
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
return { summary, schedules: newSchedules };
return { summary, schedules: newSchedules, skipped, warnings };
} catch (error) {
await client.query("ROLLBACK");
logger.error("자동 스케줄 생성 실패", { companyCode, error });

View File

@@ -67,6 +67,28 @@ function normStr(v: any): string {
return String(v).trim();
}
/**
* category_values에서 라벨로 value_code resolve (회사별)
* - 코드값은 회사/환경마다 다르므로 라벨 기준으로 조회
* - 매칭 실패 시 null 반환 (호출부에서 빈값 처리)
*/
async function resolveCategoryCode(
client: any,
companyCode: string,
tableName: string,
columnName: string,
label: string
): Promise<string | null> {
const r = await client.query(
`SELECT value_code FROM category_values
WHERE company_code = $1 AND table_name = $2 AND column_name = $3
AND TRIM(value_label) = $4
LIMIT 1`,
[companyCode, tableName, columnName, label]
);
return r.rows.length > 0 ? r.rows[0].value_code : null;
}
/**
* 엑셀 한 줄 → item_info에서 기존 품목 조회, 없으면 INSERT
* 반환: { itemNumber: string, created: boolean }
@@ -125,18 +147,29 @@ async function resolveOrCreateItem(
const seq = (Number(seqR.rows[0]?.c) || 0) + 1;
const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`;
const division = normStr(row.division) || defaultDivision;
const unit = normStr(row.unit) || "EA";
// 항목 9: 관리품목="영업관리", 품목구분="제품", 상태="활성" 고정값
// category_values에서 COMPANY_9 기준 라벨→코드 resolve (코드 직접 하드코딩 금지)
const divisionCode =
(await resolveCategoryCode(client, companyCode, "item_info", "division", "영업관리")) || "";
const typeCode =
(await resolveCategoryCode(client, companyCode, "item_info", "type", "제품")) || "";
const statusCode =
(await resolveCategoryCode(client, companyCode, "item_info", "status", "활성")) || "";
// 항목 12: 수주 디테일 단가 → 판매단가(selling_price). 기준단가(standard_price) 아님
const sellingPrice = toNum(row.unit_price);
await client.query(
`INSERT INTO item_info (
id, company_code, item_number, item_name,
size, width, height, thickness,
unit, division, status, writer, created_date
unit, division, type, status, selling_price, writer, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3,
$4, $5, $6, $7,
$8, $9, 'active', $10, NOW()
$8, $9, $10, $11, $12, $13, NOW()
)`,
[
companyCode,
@@ -147,7 +180,10 @@ async function resolveOrCreateItem(
toNum(row.height) || null,
toNum(row.thickness) || null,
unit,
division,
divisionCode,
typeCode,
statusCode,
sellingPrice ? String(sellingPrice) : null,
userId,
]
);