Refactor production plan service to handle empty due dates

- Updated the `getOrderSummary` function to use `NULLIF` for `due_date` to ensure proper handling of empty values.
- Added pagination state management in the Equipment Inspection Record page across multiple company components, including `page`, `pageSize`, and `total` state variables.

This refactor improves data accuracy and user experience in the production planning and equipment inspection modules.
This commit is contained in:
kjs
2026-04-29 13:59:52 +09:00
parent bb17cd0c33
commit 198cb92a91
18 changed files with 1294 additions and 83 deletions

View File

@@ -0,0 +1,304 @@
/**
* 수주 변경 이력 통합 조회 컨트롤러
* - sales_order_mng_log (마스터) + sales_order_detail_log (라인) 통합 타임라인
* - 컬럼명을 회사별 한글 라벨로 변환 (table_type_columns.displayName)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
interface TimelineEvent {
log_id: number;
changed_at: string;
changed_by: string | null;
changed_by_name?: string | null; // user_info.user_name (화면 표시용)
action: "INSERT" | "UPDATE" | "DELETE";
ref_table: "master" | "detail";
ref_label: string; // 화면 표시용 (예: "수주", "라인 #2")
original_id: string;
changed_column: string | null;
changed_column_label: string | null;
old_value: string | null;
new_value: string | null;
}
export async function getOrderAuditLog(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const orderNo = (req.params.orderNo || "").trim();
if (!orderNo) {
return res.status(400).json({ success: false, message: "orderNo가 필요합니다" });
}
const pool = getPool();
// 1) 마스터 ID와 디테일 ID 목록 (현재 살아있는 행 기준)
const masterRes = await pool.query(
`SELECT id::text AS id FROM sales_order_mng WHERE company_code = $1 AND order_no = $2`,
[companyCode, orderNo]
);
const masterIds: string[] = masterRes.rows.map((r) => r.id);
const detailRes = await pool.query(
`SELECT id::text AS id FROM sales_order_detail WHERE company_code = $1 AND order_no = $2`,
[companyCode, orderNo]
);
const detailIds: string[] = detailRes.rows.map((r) => r.id);
// 2) 컬럼 한글 라벨 맵 (회사별 column_label 우선, 공통 fallback)
const labelRes = await pool.query(
`SELECT table_name, column_name, company_code,
COALESCE(NULLIF(column_label, ''), column_name) AS label
FROM table_type_columns
WHERE table_name IN ('sales_order_mng', 'sales_order_detail')
AND (company_code = $1 OR company_code IS NULL OR company_code = '')`,
[companyCode]
);
const labelMap = new Map<string, string>();
// 우선 공통 라벨 채움, 그 위에 회사별 라벨로 덮어쓰기
for (const r of labelRes.rows) {
if (!r.company_code) labelMap.set(`${r.table_name}::${r.column_name}`, r.label);
}
for (const r of labelRes.rows) {
if (r.company_code) labelMap.set(`${r.table_name}::${r.column_name}`, r.label);
}
// 3) 마스터 로그 조회 (현 마스터 ID + full_row_after/before에서 order_no 매칭으로 삭제 이력까지)
const masterLogQuery = `
SELECT log_id, operation_type AS action, original_id,
changed_column, old_value, new_value, changed_by, changed_at,
'master'::text AS ref_table
FROM sales_order_mng_log
WHERE (
($1::text[] IS NOT NULL AND original_id = ANY($1::text[]))
OR full_row_after->>'order_no' = $2
OR full_row_before->>'order_no' = $2
)
AND COALESCE(full_row_after->>'company_code', full_row_before->>'company_code') = $3
`;
const masterLogRes = await pool.query(masterLogQuery, [masterIds, orderNo, companyCode]);
// 4) 디테일 로그는 5-A 단계에서 full_row_before/after까지 함께 조회
// 5) 두 결과 병합 + 컬럼 라벨 매핑 + 정렬
// 5-A) detail 로그에서 "전체 DELETE → 다시 INSERT" 노이즈를 UPDATE로 합치기
// - 같은 시각(초 단위) 내에서 part_code 매칭으로 페어링
// - full_row_before/after를 diff하여 실제 변경된 필드만 UPDATE 이벤트 생성
// - 변경 필드 없는 페어(완전 동일)는 이벤트 자체 생략
// - 매칭 안 된 단독 INSERT/DELETE는 그대로 유지
const detailIgnoredColumns = new Set([
"id", "created_date", "updated_date", "writer", "seq_no",
]);
type DetailRaw = {
log_id: number;
action: "INSERT" | "UPDATE" | "DELETE";
original_id: string;
changed_column: string | null;
old_value: string | null;
new_value: string | null;
changed_by: string | null;
changed_at: string;
seq_no: string | null;
part_code: string | null;
full_row_before?: any;
full_row_after?: any;
};
// 같은 시각(초 단위)으로 그룹핑
const detailRawRows: DetailRaw[] = (await pool.query(
`SELECT log_id, operation_type AS action, original_id,
changed_column, old_value, new_value, changed_by, changed_at,
COALESCE(full_row_after->>'seq_no', full_row_before->>'seq_no') AS seq_no,
COALESCE(full_row_after->>'part_code', full_row_before->>'part_code') AS part_code,
full_row_before, full_row_after
FROM sales_order_detail_log
WHERE (
($1::text[] IS NOT NULL AND original_id = ANY($1::text[]))
OR full_row_after->>'order_no' = $2
OR full_row_before->>'order_no' = $2
)
AND COALESCE(full_row_after->>'company_code', full_row_before->>'company_code') = $3`,
[detailIds, orderNo, companyCode]
)).rows;
const detailFinalEvents: TimelineEvent[] = [];
const groupedBySec = new Map<string, DetailRaw[]>();
for (const r of detailRawRows) {
const sec = new Date(r.changed_at).toISOString().slice(0, 19);
if (!groupedBySec.has(sec)) groupedBySec.set(sec, []);
groupedBySec.get(sec)!.push(r);
}
Array.from(groupedBySec.values()).forEach((group) => {
// UPDATE 직접 발생한 건 그대로 통과
const directUpdates = group.filter((g) => g.action === "UPDATE");
const inserts = group.filter((g) => g.action === "INSERT");
const deletes = group.filter((g) => g.action === "DELETE");
// part_code 키로 INSERT/DELETE 매칭 (같은 키는 가장 빠른 것끼리 짝)
const insertsByKey = new Map<string, DetailRaw[]>();
for (const ins of inserts) {
const k = ins.part_code || "__nokey__";
if (!insertsByKey.has(k)) insertsByKey.set(k, []);
insertsByKey.get(k)!.push(ins);
}
const matchedInsertIds = new Set<number>();
const matchedDeleteIds = new Set<number>();
for (const del of deletes) {
const k = del.part_code || "__nokey__";
const candidates = insertsByKey.get(k);
if (!candidates || candidates.length === 0) continue;
const ins = candidates.shift()!;
matchedInsertIds.add(ins.log_id);
matchedDeleteIds.add(del.log_id);
const before = del.full_row_before || {};
const after = ins.full_row_after || {};
const allKeys = Array.from(new Set<string>([...Object.keys(before), ...Object.keys(after)]));
for (const key of allKeys) {
if (detailIgnoredColumns.has(key)) continue;
const oldVal = before[key] ?? null;
const newVal = after[key] ?? null;
if (String(oldVal ?? "") === String(newVal ?? "")) continue;
const seqLabel = (after.seq_no || before.seq_no) ? `라인 #${after.seq_no || before.seq_no}` : "라인";
const partLabel = (after.part_code || before.part_code) ? ` (${after.part_code || before.part_code})` : "";
detailFinalEvents.push({
log_id: ins.log_id, // 정렬 안정성용
changed_at: ins.changed_at,
changed_by: ins.changed_by,
action: "UPDATE",
ref_table: "detail",
ref_label: `${seqLabel}${partLabel}`,
original_id: ins.original_id,
changed_column: key,
changed_column_label: labelMap.get(`sales_order_detail::${key}`) || key,
old_value: oldVal != null ? String(oldVal) : null,
new_value: newVal != null ? String(newVal) : null,
});
}
}
// 매칭 안 된 단독 INSERT (= 새 라인 추가)
for (const ins of inserts) {
if (matchedInsertIds.has(ins.log_id)) continue;
const seqLabel = ins.seq_no ? `라인 #${ins.seq_no}` : "라인";
const partLabel = ins.part_code ? ` (${ins.part_code})` : "";
detailFinalEvents.push({
log_id: ins.log_id,
changed_at: ins.changed_at,
changed_by: ins.changed_by,
action: "INSERT",
ref_table: "detail",
ref_label: `${seqLabel}${partLabel}`,
original_id: ins.original_id,
changed_column: null,
changed_column_label: null,
old_value: null,
new_value: null,
});
}
// 매칭 안 된 단독 DELETE (= 라인 제거)
for (const del of deletes) {
if (matchedDeleteIds.has(del.log_id)) continue;
const seqLabel = del.seq_no ? `라인 #${del.seq_no}` : "라인";
const partLabel = del.part_code ? ` (${del.part_code})` : "";
detailFinalEvents.push({
log_id: del.log_id,
changed_at: del.changed_at,
changed_by: del.changed_by,
action: "DELETE",
ref_table: "detail",
ref_label: `${seqLabel}${partLabel}`,
original_id: del.original_id,
changed_column: null,
changed_column_label: null,
old_value: null,
new_value: null,
});
}
// 직접 UPDATE 이벤트
for (const u of directUpdates) {
const seqLabel = u.seq_no ? `라인 #${u.seq_no}` : "라인";
const partLabel = u.part_code ? ` (${u.part_code})` : "";
detailFinalEvents.push({
log_id: u.log_id,
changed_at: u.changed_at,
changed_by: u.changed_by,
action: "UPDATE",
ref_table: "detail",
ref_label: `${seqLabel}${partLabel}`,
original_id: u.original_id,
changed_column: u.changed_column,
changed_column_label: u.changed_column
? labelMap.get(`sales_order_detail::${u.changed_column}`) || u.changed_column
: null,
old_value: u.old_value,
new_value: u.new_value,
});
}
});
const timeline: TimelineEvent[] = [];
for (const r of masterLogRes.rows) {
// 마스터의 updated_date 같은 메타 필드는 노이즈 → 숨김
if (r.changed_column && ["updated_date", "updated_by"].includes(r.changed_column)) continue;
timeline.push({
log_id: r.log_id,
changed_at: r.changed_at,
changed_by: r.changed_by,
action: r.action,
ref_table: "master",
ref_label: "수주",
original_id: r.original_id,
changed_column: r.changed_column,
changed_column_label: r.changed_column
? labelMap.get(`sales_order_mng::${r.changed_column}`) || r.changed_column
: null,
old_value: r.old_value,
new_value: r.new_value,
});
}
timeline.push(...detailFinalEvents);
// 시간 역순 (최신 먼저), 동일 시각이면 log_id 역순
timeline.sort((a, b) => {
const ta = new Date(a.changed_at).getTime();
const tb = new Date(b.changed_at).getTime();
if (ta !== tb) return tb - ta;
return b.log_id - a.log_id;
});
// changed_by(user_id)를 사용자명(user_info.user_name)으로 매핑
const userIds = Array.from(new Set(timeline.map((t) => t.changed_by).filter((v): v is string => !!v && v.trim() !== "")));
const userNameMap = new Map<string, string>();
if (userIds.length > 0) {
const userRes = await pool.query(
`SELECT user_id, COALESCE(NULLIF(user_name, ''), user_id) AS user_name
FROM user_info WHERE user_id = ANY($1::text[])`,
[userIds]
);
for (const u of userRes.rows) userNameMap.set(u.user_id, u.user_name);
}
for (const ev of timeline) {
ev.changed_by_name = ev.changed_by ? (userNameMap.get(ev.changed_by) || ev.changed_by) : null;
}
logger.info("수주 audit log 조회", {
companyCode,
orderNo,
masterEvents: masterLogRes.rowCount,
detailEvents: detailFinalEvents.length,
});
return res.json({
success: true,
orderNo,
total: timeline.length,
timeline,
});
} catch (error: any) {
logger.error("수주 audit log 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,53 @@
/**
* 수주 등록/수정 모달 — 품목별 등록 포장재 옵션 조회
* - pkg_unit_item에서 item_number로 매핑된 포장재 + 입수수량
* - pkg_unit JOIN으로 포장재 이름·종류 함께 반환
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export async function getPackagingOptions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const itemNumber = (req.params.itemNumber || "").trim();
if (!itemNumber) {
return res.status(400).json({ success: false, message: "itemNumber가 필요합니다" });
}
const pool = getPool();
const result = await pool.query(
`SELECT
pui.pkg_code,
pui.pkg_qty,
pu.pkg_name,
pu.pkg_type
FROM pkg_unit_item pui
LEFT JOIN pkg_unit pu
ON pu.pkg_code = pui.pkg_code AND pu.company_code = pui.company_code
WHERE pui.company_code = $1
AND pui.item_number = $2
ORDER BY pui.created_date ASC`,
[companyCode, itemNumber]
);
const options = result.rows.map((r: any) => ({
pkg_code: r.pkg_code,
pkg_name: r.pkg_name || r.pkg_code,
pkg_type: r.pkg_type || "",
pkg_qty_per_unit: Number(r.pkg_qty) || 0, // 1 포장당 입수 수량
}));
return res.json({
success: true,
itemNumber,
total: options.length,
options,
});
} catch (error: any) {
logger.error("품목별 포장재 옵션 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -1215,6 +1215,16 @@ export async function editTableData(
}
}
// 🆕 updated_by 자동 추가 (테이블에 컬럼이 있고 클라이언트가 명시 안 한 경우)
// audit 트리거가 NEW.updated_by를 fallback으로 사용하므로 수정자 식별에 필요
const editorUserId = req.user?.userId;
if (editorUserId && !updatedData.updated_by) {
const hasUpdatedByCol = await tableManagementService.hasColumn(tableName, "updated_by");
if (hasUpdatedByCol) {
updatedData.updated_by = editorUserId;
}
}
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,