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:
304
backend-node/src/controllers/salesOrderAuditController.ts
Normal file
304
backend-node/src/controllers/salesOrderAuditController.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user