Merge remote-tracking branch 'origin/jskim-node' into jskim-node
; Conflicts: ; frontend/app/(main)/COMPANY_30/sales/order/page.tsx
This commit is contained in:
@@ -892,6 +892,40 @@ export class ApprovalRequestController {
|
||||
const userName = req.user?.userName || "";
|
||||
const deptName = req.user?.deptName || "";
|
||||
|
||||
// 🔒 중복 결재 차단: 같은 target에 활성/완료된 결재가 있으면 거부
|
||||
// (rejected, cancelled는 재상신 허용)
|
||||
if (target_record_id) {
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT request_id, status FROM approval_requests
|
||||
WHERE target_table = $1 AND target_record_id = $2 AND company_code = $3
|
||||
AND status IN ('requested', 'in_progress', 'approved', 'post_pending')
|
||||
ORDER BY request_id DESC LIMIT 1`,
|
||||
[target_table, safeTargetRecordId, companyCode]
|
||||
);
|
||||
if (existing) {
|
||||
const statusLabel: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `이미 ${statusLabel[existing.status] || existing.status} 상태의 결재가 존재합니다. (요청 ID: ${existing.request_id})`,
|
||||
error: { code: "DUPLICATE_APPROVAL", details: existing },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 자기 자신 결재 차단: approval_type이 'self'가 아니면 결재선에 본인 포함 불가
|
||||
if (approval_type !== "self" && Array.isArray(approvers)) {
|
||||
const selfInLine = approvers.find((a: any) => (a.userId || a.user_id) === userId);
|
||||
if (selfInLine) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "결재선에 본인을 포함할 수 없습니다. 자기결재(전결)는 별도 유형을 사용해 주세요.",
|
||||
error: { code: "SELF_APPROVER_NOT_ALLOWED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// approval_mode를 target_record_data에 병합 저장 (하위호환)
|
||||
const mergedRecordData = {
|
||||
...(target_record_data || {}),
|
||||
|
||||
@@ -207,6 +207,17 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
const insertedDetails: any[] = [];
|
||||
|
||||
// 기존 디테일이 있으면 스킵 (멱등성 — 같은 inbound_number로 2번 호출 방지)
|
||||
const existingDetails = await client.query(
|
||||
`SELECT COUNT(*) AS cnt FROM inbound_detail WHERE company_code = $1 AND inbound_id = $2`,
|
||||
[companyCode, inboundNumber]
|
||||
);
|
||||
if (parseInt(existingDetails.rows[0].cnt, 10) > 0) {
|
||||
await client.query("COMMIT");
|
||||
client.release();
|
||||
return res.json({ success: true, data: [], message: "이미 등록된 입고입니다." });
|
||||
}
|
||||
|
||||
// 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
@@ -907,6 +907,61 @@ export async function getTableData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 집계 조회 (SUM/COUNT)
|
||||
* POST /api/table-management/tables/:tableName/aggregate
|
||||
*/
|
||||
export async function getTableAggregate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columns, autoFilter } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!tableName || !columns || !Array.isArray(columns)) {
|
||||
res.status(400).json({ success: false, message: "tableName과 columns 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validCols = columns.filter((c: any) =>
|
||||
c.column && c.func && /^[a-zA-Z0-9_]+$/.test(c.column) && ["sum", "count", "avg", "min", "max"].includes(c.func)
|
||||
);
|
||||
if (validCols.length === 0) {
|
||||
res.status(400).json({ success: false, message: "유효한 집계 컬럼이 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectParts = validCols.map((c: any) => {
|
||||
const col = c.column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
return `${c.func}(COALESCE(CAST(NULLIF(${col}, '') AS numeric), 0)) AS "${c.func}_${col}"`;
|
||||
});
|
||||
|
||||
let whereClause = "";
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (autoFilter !== false && companyCode && companyCode !== "*") {
|
||||
whereClause = `WHERE company_code = $${paramIdx}`;
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = (await import("../database/db")).getPool();
|
||||
const safeTable = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectParts.join(", ")} FROM ${safeTable} ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows[0] || {} });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 집계 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 추가
|
||||
*/
|
||||
|
||||
@@ -372,6 +372,69 @@ export async function getRoutingVersions(req: AuthenticatedRequest, res: Respons
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목별 라우팅 벌크 조회 (엑셀 업로드용) ───
|
||||
export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCodes } = req.body as { itemCodes: string[] };
|
||||
|
||||
if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) {
|
||||
return res.json({ success: true, data: {} });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result: Record<string, { code: string; name: string }[]> = {};
|
||||
|
||||
// 청크 단위로 분할 (PostgreSQL placeholder 제한 대응)
|
||||
const CHUNK_SIZE = 5000;
|
||||
for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) {
|
||||
const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE);
|
||||
|
||||
// 1. 기본 라우팅 버전 조회
|
||||
const placeholders = chunk.map((_, i) => `$${i + 2}`).join(",");
|
||||
const versionsResult = await pool.query(
|
||||
`SELECT DISTINCT ON (item_code) id, item_code, version_name
|
||||
FROM item_routing_version
|
||||
WHERE company_code = $1 AND item_code IN (${placeholders})
|
||||
ORDER BY item_code, is_default DESC, created_date DESC`,
|
||||
[companyCode, ...chunk]
|
||||
);
|
||||
|
||||
if (versionsResult.rows.length === 0) continue;
|
||||
|
||||
// 2. 라우팅 디테일 조회
|
||||
const versionIds = versionsResult.rows.map((v: any) => v.id);
|
||||
const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(",");
|
||||
const detailsResult = await pool.query(
|
||||
`SELECT rd.routing_version_id, rd.process_code,
|
||||
COALESCE(p.process_name, rd.process_code) AS process_name
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||
WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders})
|
||||
ORDER BY rd.seq_no::integer`,
|
||||
[companyCode, ...versionIds]
|
||||
);
|
||||
|
||||
// 3. 매핑
|
||||
const versionToItem: Record<string, string> = {};
|
||||
for (const v of versionsResult.rows) {
|
||||
versionToItem[v.id] = v.item_code;
|
||||
}
|
||||
for (const d of detailsResult.rows) {
|
||||
const itemCode = versionToItem[d.routing_version_id];
|
||||
if (!itemCode) continue;
|
||||
if (!result[itemCode]) result[itemCode] = [];
|
||||
result[itemCode].push({ code: d.process_code, name: d.process_name });
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("벌크 라우팅 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 라우팅 변경 ───
|
||||
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
updateColumnInputType,
|
||||
updateTableLabel,
|
||||
getTableData,
|
||||
getTableRecord, // 🆕 단일 레코드 조회
|
||||
getTableRecord,
|
||||
getTableAggregate,
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
@@ -193,6 +194,7 @@ router.get("/health", checkDatabaseConnection);
|
||||
* POST /api/table-management/tables/:tableName/data
|
||||
*/
|
||||
router.post("/tables/:tableName/data", getTableData);
|
||||
router.post("/tables/:tableName/aggregate", getTableAggregate);
|
||||
|
||||
/**
|
||||
* 단일 레코드 조회 (자동 입력용)
|
||||
|
||||
@@ -15,6 +15,9 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource);
|
||||
router.get("/equipment", ctrl.getEquipmentList);
|
||||
router.get("/employees", ctrl.getEmployeeList);
|
||||
|
||||
// 벌크 라우팅 조회 (품목별 공정 일괄 조회)
|
||||
router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk);
|
||||
|
||||
// 라우팅 & 공정작업기준
|
||||
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||
router.put("/:wiNo/routing", ctrl.updateRouting);
|
||||
|
||||
@@ -2367,26 +2367,24 @@ export class TableManagementService {
|
||||
const total = parseInt(countResult[0].count);
|
||||
|
||||
// 데이터 조회 (main 별칭 추가)
|
||||
const dataQuery = `
|
||||
SELECT main.* FROM ${safeTableName} main
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
// size=0 이면 LIMIT 없이 전체 반환 (마스터 참조 데이터 조회용)
|
||||
const usePaging = size > 0;
|
||||
const dataQuery = usePaging
|
||||
? `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`
|
||||
: `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause}`;
|
||||
|
||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||
logger.info(
|
||||
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
||||
);
|
||||
const queryParams = usePaging ? [...searchValues, size, offset] : [...searchValues];
|
||||
logger.info(`🔍 파라미터: ${JSON.stringify(queryParams)}`);
|
||||
|
||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||
let data = await query<any>(dataQuery, queryParams);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
if (fileColumns.length > 0) {
|
||||
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
const totalPages = usePaging ? Math.ceil(total / size) : 1;
|
||||
|
||||
logger.info(
|
||||
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
||||
|
||||
Reference in New Issue
Block a user