Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev
This commit is contained in:
@@ -461,6 +461,8 @@ async function initializeServices() {
|
||||
runApprovalSystemMigration,
|
||||
runUserMailAccountsMigration,
|
||||
runMessengerMigration,
|
||||
runSmartFactoryLogMigration,
|
||||
runSmartFactoryScheduleMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
@@ -469,6 +471,8 @@ async function initializeServices() {
|
||||
await runApprovalSystemMigration();
|
||||
await runUserMailAccountsMigration();
|
||||
await runMessengerMigration();
|
||||
await runSmartFactoryLogMigration();
|
||||
await runSmartFactoryScheduleMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
@@ -482,6 +486,11 @@ async function initializeServices() {
|
||||
const { CrawlService } = await import("./services/crawlService");
|
||||
await CrawlService.initializeScheduler();
|
||||
logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`);
|
||||
|
||||
// 스마트공장 로그 스케줄러 초기화
|
||||
const { initSmartFactoryScheduler } = await import("./utils/smartFactoryLog");
|
||||
await initSmartFactoryScheduler();
|
||||
logger.info(`🏭 스마트공장 로그 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export class AuthController {
|
||||
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||
sendSmartFactoryLog({
|
||||
userId: userInfo.userId,
|
||||
userName: userInfo.userName,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
companyCode: userInfo.companyCode,
|
||||
|
||||
@@ -104,11 +104,11 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_mng (
|
||||
company_code, mold_code, mold_name, mold_type, category,
|
||||
id, company_code, mold_code, mold_name, mold_type, category,
|
||||
manufacturer, manufacturing_number, manufacturing_date,
|
||||
cavity_count, shot_count, mold_quantity, base_input_qty,
|
||||
operation_status, remarks, image_path, memo, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
operation_status, remarks, image_path, memo, writer, created_date
|
||||
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
@@ -231,7 +231,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { moldCode } = req.params;
|
||||
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
|
||||
const { serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location } = req.body;
|
||||
|
||||
let finalSerialNumber = serial_number;
|
||||
|
||||
@@ -266,14 +266,15 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
INSERT INTO mold_serial (id, company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location, writer, created_date)
|
||||
VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, moldCode, finalSerialNumber, status || "STORED",
|
||||
progress || 0, work_description || null, manager || null,
|
||||
completion_date || null, remarks || null, userId,
|
||||
completion_date || null, remarks || null, current_shot_count || 0,
|
||||
storage_location || null, userId,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
@@ -288,6 +289,38 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { status, current_shot_count, storage_location, remarks } = req.body;
|
||||
|
||||
const sql = `
|
||||
UPDATE mold_serial SET
|
||||
status = COALESCE($1, status),
|
||||
current_shot_count = COALESCE($2, current_shot_count),
|
||||
storage_location = $3,
|
||||
remarks = $4,
|
||||
updated_date = NOW()
|
||||
WHERE id = $5 AND company_code = $6
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [status, current_shot_count, storage_location || null, remarks || null, id, companyCode];
|
||||
const result = await query(sql, params);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("일련번호 수정", { companyCode, id });
|
||||
res.json({ success: true, data: result[0], message: "일련번호가 수정되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("일련번호 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
@@ -347,10 +380,10 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_inspection_item (
|
||||
company_code, mold_code, inspection_item, inspection_cycle,
|
||||
id, company_code, mold_code, inspection_item, inspection_cycle,
|
||||
inspection_method, inspection_content, lower_limit, upper_limit,
|
||||
unit, is_active, checklist, remarks, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
unit, is_active, checklist, remarks, writer, created_date
|
||||
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
@@ -426,10 +459,10 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response):
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_part (
|
||||
company_code, mold_code, part_name, replacement_cycle,
|
||||
id, company_code, mold_code, part_name, replacement_cycle,
|
||||
unit, specification, manufacturer, manufacturer_code,
|
||||
image_path, remarks, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
image_path, remarks, writer, created_date
|
||||
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
|
||||
@@ -201,13 +201,32 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
||||
[companyCode, itemCode, whCode, locCode, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 재고 이력 기록 (inventory_history)
|
||||
const afterStockRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW(), $5, $6, $7, $8, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트
|
||||
@@ -496,7 +515,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
@@ -507,3 +526,25 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고별 위치 목록 조회
|
||||
export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const warehouseCode = req.query.warehouse_code as string;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT location_code, location_name, warehouse_code
|
||||
FROM warehouse_location
|
||||
WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""}
|
||||
ORDER BY location_code`,
|
||||
warehouseCode ? [companyCode, warehouseCode] : [companyCode]
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,15 @@ export async function getProcessList(req: AuthenticatedRequest, res: Response) {
|
||||
params.push(processType);
|
||||
}
|
||||
if (useYn) {
|
||||
conditions.push(`use_yn = $${idx++}`);
|
||||
params.push(useYn);
|
||||
// "Y" → "USE_Y"도 매칭, "N" → "USE_N"도 매칭
|
||||
const useYnValue = String(useYn);
|
||||
if (useYnValue === "Y" || useYnValue === "N") {
|
||||
conditions.push(`use_yn IN ($${idx++}, $${idx++})`);
|
||||
params.push(useYnValue, `USE_${useYnValue}`);
|
||||
} else {
|
||||
conditions.push(`use_yn = $${idx++}`);
|
||||
params.push(useYnValue);
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
@@ -463,7 +463,7 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
|
||||
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
created_date
|
||||
selected_bom_items, created_date
|
||||
FROM process_work_item_detail
|
||||
WHERE work_item_id = $1 AND company_code = $2
|
||||
ORDER BY sort_order, created_date
|
||||
@@ -492,6 +492,7 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
selected_bom_items,
|
||||
} = req.body;
|
||||
|
||||
if (!work_item_id || !content) {
|
||||
@@ -514,11 +515,14 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
duration_minutes, input_type, lookup_target, display_fields, selected_bom_items)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// selected_bom_items: 배열이면 JSON 문자열로 변환
|
||||
const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items || null;
|
||||
|
||||
const result = await getPool().query(query, [
|
||||
companyCode,
|
||||
work_item_id,
|
||||
@@ -537,6 +541,7 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
bomItemsJson,
|
||||
]);
|
||||
|
||||
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||
@@ -562,8 +567,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
selected_bom_items,
|
||||
} = req.body;
|
||||
|
||||
const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null;
|
||||
|
||||
const query = `
|
||||
UPDATE process_work_item_detail
|
||||
SET detail_type = COALESCE($1, detail_type),
|
||||
@@ -580,6 +588,7 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
input_type = $14,
|
||||
lookup_target = $15,
|
||||
display_fields = $16,
|
||||
selected_bom_items = $17,
|
||||
updated_date = NOW()
|
||||
WHERE id = $6 AND company_code = $7
|
||||
RETURNING *
|
||||
@@ -602,6 +611,7 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
bomItemsJson,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
@@ -889,7 +899,22 @@ export async function registerItemsBatch(req: AuthenticatedRequest, res: Respons
|
||||
RETURNING *`,
|
||||
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||
);
|
||||
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||
if (result.rows[0]) {
|
||||
inserted.push(result.rows[0]);
|
||||
// 기본 라우팅 버전이 없으면 자동 생성
|
||||
const itemCode = item.itemCode || item.itemId;
|
||||
const existingVersion = await client.query(
|
||||
`SELECT id FROM item_routing_version WHERE item_code = $1 AND company_code = $2 LIMIT 1`,
|
||||
[itemCode, companyCode]
|
||||
);
|
||||
if (existingVersion.rowCount === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, '기본', '자동 생성된 기본 라우팅', true, $3)`,
|
||||
[companyCode, itemCode, req.user?.userId || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
@@ -253,6 +253,25 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 2b-2. 재고 이력 기록 (inventory_history)
|
||||
const afterStockRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
|
||||
@@ -455,6 +474,7 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
@@ -516,6 +536,25 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
|
||||
AND COALESCE(location_code, '') = COALESCE($5, '')`,
|
||||
[inQty, companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
// 입고취소 이력 기록
|
||||
const afterStockRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 구매입고 발주 롤백: purchase_order_mng 기반
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
// 스마트공장 활용 로그 조회 컨트롤러
|
||||
// 최고관리자(*) 전용 — 회사별 필터링 가능
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/permissionMiddleware";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { encryptionService } from "../services/encryptionService";
|
||||
import {
|
||||
runScheduleNow,
|
||||
getTodayPlanStatus,
|
||||
planDailySends,
|
||||
} from "../utils/smartFactoryLog";
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log
|
||||
* 스마트공장 로그 목록 조회
|
||||
*/
|
||||
export const getSmartFactoryLogs = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
sendStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
search,
|
||||
page = "1",
|
||||
limit = "50",
|
||||
} = req.query;
|
||||
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode && companyCode !== "all") {
|
||||
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
||||
queryParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 사용자 필터
|
||||
if (userId && (userId as string).trim()) {
|
||||
whereConditions.push(`sfl.user_id ILIKE $${paramIndex}`);
|
||||
queryParams.push(`%${(userId as string).trim()}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 전송 상태 필터
|
||||
if (sendStatus && sendStatus !== "all") {
|
||||
whereConditions.push(`sfl.send_status = $${paramIndex}`);
|
||||
queryParams.push(sendStatus);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (dateFrom) {
|
||||
whereConditions.push(`sfl.created_at >= $${paramIndex}`);
|
||||
queryParams.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereConditions.push(`sfl.created_at < ($${paramIndex}::date + 1)`);
|
||||
queryParams.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 통합 검색
|
||||
if (search && (search as string).trim()) {
|
||||
whereConditions.push(
|
||||
`(sfl.user_id ILIKE $${paramIndex} OR sfl.user_name ILIKE $${paramIndex} OR sfl.connect_ip ILIKE $${paramIndex} OR sfl.error_message ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${(search as string).trim()}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 총 개수
|
||||
const countResult = await queryOne<{ total: string }>(
|
||||
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
const total = parseInt(countResult?.total || "0", 10);
|
||||
|
||||
// 페이지네이션
|
||||
const pageNum = Math.max(1, parseInt(page as string, 10));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10)));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
// 데이터 조회 (회사명 JOIN)
|
||||
const logs = await query<any>(
|
||||
`SELECT sfl.*, cm.company_name
|
||||
FROM smart_factory_log sfl
|
||||
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
||||
${whereClause}
|
||||
ORDER BY sfl.created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...queryParams, limitNum, offset]
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: logs,
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("스마트공장 로그 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "스마트공장 로그 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/stats
|
||||
* 스마트공장 로그 통계 (회사별 요약)
|
||||
*/
|
||||
export const getSmartFactoryLogStats = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, days = "30" } = req.query;
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
|
||||
const whereConditions: string[] = [
|
||||
`sfl.created_at >= NOW() - INTERVAL '${daysNum} days'`,
|
||||
];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode && companyCode !== "all") {
|
||||
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
||||
queryParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||
|
||||
// 상태별 건수
|
||||
const statusCounts = await query<{ send_status: string; count: string }>(
|
||||
`SELECT send_status, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
${whereClause}
|
||||
GROUP BY send_status`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 회사별 건수
|
||||
const companyCounts = await query<{
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT sfl.company_code, COALESCE(cm.company_name, sfl.company_code) as company_name, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
||||
${whereClause}
|
||||
GROUP BY sfl.company_code, cm.company_name
|
||||
ORDER BY count DESC`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 일별 추이
|
||||
const dailyCounts = await query<{ date: string; count: string }>(
|
||||
`SELECT DATE(sfl.created_at) as date, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
${whereClause}
|
||||
GROUP BY DATE(sfl.created_at)
|
||||
ORDER BY date DESC
|
||||
LIMIT ${daysNum}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 전체 건수
|
||||
const totalResult = await queryOne<{ total: string }>(
|
||||
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
total: parseInt(totalResult?.total || "0", 10),
|
||||
statusCounts: statusCounts.map((r) => ({
|
||||
status: r.send_status,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
companyCounts: companyCounts.map((r) => ({
|
||||
companyCode: r.company_code,
|
||||
companyName: r.company_name,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
dailyCounts: dailyCounts.map((r) => ({
|
||||
date: r.date,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("스마트공장 로그 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "통계 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 스케줄 관리 API ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/schedules
|
||||
*/
|
||||
export const getSchedules = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const schedules = await query<any>(
|
||||
`SELECT s.*, cm.company_name
|
||||
FROM smart_factory_schedule s
|
||||
LEFT JOIN company_mng cm ON cm.company_code = s.company_code
|
||||
ORDER BY s.company_code`
|
||||
);
|
||||
res.json({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/schedules
|
||||
*/
|
||||
export const upsertSchedule = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body;
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({ success: false, message: "회사코드는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (company_code) DO UPDATE SET
|
||||
is_active = $2, time_start = $3, time_end = $4,
|
||||
exclude_weekend = $5, exclude_holidays = $6, updated_at = NOW()`,
|
||||
[
|
||||
companyCode,
|
||||
isActive ?? false,
|
||||
timeStart || "08:30",
|
||||
timeEnd || "17:30",
|
||||
excludeWeekend ?? true,
|
||||
excludeHolidays ?? true,
|
||||
]
|
||||
);
|
||||
|
||||
// 스케줄 변경 시 오늘 계획 재생성
|
||||
await planDailySends();
|
||||
|
||||
res.json({ success: true, message: "스케줄이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 저장 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 저장 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/schedules/:companyCode
|
||||
*/
|
||||
export const deleteSchedule = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
await query("DELETE FROM smart_factory_schedule WHERE company_code = $1", [companyCode]);
|
||||
res.json({ success: true, message: "스케줄이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/schedules/:companyCode/run-now
|
||||
*/
|
||||
export const runScheduleNowHandler = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
const result = await runScheduleNow(companyCode);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error("즉시 실행 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "즉시 실행 실패",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/schedules/today-plan
|
||||
*/
|
||||
export const getTodayPlanHandler = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const plan = getTodayPlanStatus();
|
||||
res.json({ success: true, data: plan });
|
||||
} catch (error) {
|
||||
logger.error("오늘 계획 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "오늘 계획 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 공휴일 관리 API ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/holidays
|
||||
*/
|
||||
export const getHolidays = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const holidays = await query<any>(
|
||||
"SELECT id, holiday_date, holiday_name, created_at FROM smart_factory_holidays ORDER BY holiday_date"
|
||||
);
|
||||
res.json({ success: true, data: holidays });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/holidays
|
||||
*/
|
||||
export const addHoliday = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { holidayDate, holidayName } = req.body;
|
||||
|
||||
if (!holidayDate || !holidayName) {
|
||||
res.status(400).json({ success: false, message: "날짜와 이름은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await query(
|
||||
"INSERT INTO smart_factory_holidays (holiday_date, holiday_name) VALUES ($1, $2) ON CONFLICT (holiday_date) DO UPDATE SET holiday_name = $2",
|
||||
[holidayDate, holidayName]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "공휴일이 추가되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 추가 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 추가 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/holidays/:id
|
||||
*/
|
||||
export const deleteHoliday = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await query("DELETE FROM smart_factory_holidays WHERE id = $1", [id]);
|
||||
res.json({ success: true, message: "공휴일이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── API 키 관리 ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/api-keys
|
||||
* 전체 회사 목록 + API 키 상태 (DB키 여부, 환경변수 여부)
|
||||
*/
|
||||
export const getApiKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const companies = await query<any>(
|
||||
`SELECT cm.company_code, cm.company_name, ak.api_key
|
||||
FROM company_mng cm
|
||||
LEFT JOIN smart_factory_api_keys ak ON ak.company_code = cm.company_code
|
||||
WHERE cm.company_code != '*'
|
||||
ORDER BY cm.company_code`
|
||||
);
|
||||
|
||||
const result = companies.map((c: any) => {
|
||||
let dbKeyDecrypted: string | null = null;
|
||||
if (c.api_key) {
|
||||
try {
|
||||
dbKeyDecrypted = encryptionService.decrypt(c.api_key);
|
||||
} catch {
|
||||
dbKeyDecrypted = "(복호화 실패)";
|
||||
}
|
||||
}
|
||||
return {
|
||||
companyCode: c.company_code,
|
||||
companyName: c.company_name,
|
||||
hasDbKey: !!c.api_key,
|
||||
dbKey: dbKeyDecrypted,
|
||||
hasEnvKey: !!process.env[`SMART_FACTORY_API_KEY_${c.company_code}`],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error("API 키 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 목록 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/api-keys
|
||||
* API 키 저장 (암호화)
|
||||
*/
|
||||
export const saveApiKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, apiKey } = req.body;
|
||||
|
||||
if (!companyCode || !apiKey) {
|
||||
res.status(400).json({ success: false, message: "회사코드와 API 키는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = encryptionService.encrypt(apiKey);
|
||||
|
||||
await query(
|
||||
`INSERT INTO smart_factory_api_keys (company_code, api_key, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (company_code) DO UPDATE SET api_key = $2, updated_at = NOW()`,
|
||||
[companyCode, encrypted]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "API 키가 저장되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("API 키 저장 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 저장 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/api-keys/:companyCode
|
||||
* API 키 삭제 (환경변수 폴백으로 전환)
|
||||
*/
|
||||
export const deleteApiKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
|
||||
await query(
|
||||
"DELETE FROM smart_factory_api_keys WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "API 키가 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("API 키 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 삭제 실패" });
|
||||
}
|
||||
};
|
||||
@@ -170,6 +170,64 @@ export async function runMessengerMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 테이블 마이그레이션
|
||||
*/
|
||||
/**
|
||||
* 스마트공장 스케줄 + 공휴일 테이블 마이그레이션
|
||||
*/
|
||||
export async function runSmartFactoryScheduleMigration() {
|
||||
try {
|
||||
console.log("🔄 스마트공장 스케줄 테이블 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/201_create_smart_factory_schedule.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 스마트공장 스케줄 테이블 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 스마트공장 스케줄 테이블 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSmartFactoryLogMigration() {
|
||||
try {
|
||||
console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/200_create_smart_factory_log.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 스마트공장 로그 테이블 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 스마트공장 로그 테이블 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDtgManagementLogMigration() {
|
||||
try {
|
||||
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -32,6 +32,21 @@ import {
|
||||
setUserLocale,
|
||||
getTableSchema, // 테이블 스키마 조회
|
||||
} from "../controllers/adminController";
|
||||
import {
|
||||
getSmartFactoryLogs,
|
||||
getSmartFactoryLogStats,
|
||||
getSchedules,
|
||||
upsertSchedule,
|
||||
deleteSchedule,
|
||||
runScheduleNowHandler,
|
||||
getTodayPlanHandler,
|
||||
getHolidays,
|
||||
addHoliday,
|
||||
deleteHoliday,
|
||||
getApiKeys,
|
||||
saveApiKey,
|
||||
deleteApiKey,
|
||||
} from "../controllers/smartFactoryLogController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
|
||||
@@ -84,4 +99,25 @@ router.post("/user-locale", setUserLocale);
|
||||
// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용)
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
// 스마트공장 활용 로그 API (최고관리자 전용)
|
||||
router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs);
|
||||
router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats);
|
||||
|
||||
// 스마트공장 스케줄 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules);
|
||||
router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler);
|
||||
router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule);
|
||||
router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule);
|
||||
router.post("/smart-factory-log/schedules/:companyCode/run-now", requireSuperAdmin, runScheduleNowHandler);
|
||||
|
||||
// 스마트공장 공휴일 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays);
|
||||
router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday);
|
||||
router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday);
|
||||
|
||||
// 스마트공장 API 키 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys);
|
||||
router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey);
|
||||
router.delete("/smart-factory-log/api-keys/:companyCode", requireSuperAdmin, deleteApiKey);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
deleteMold,
|
||||
getMoldSerials,
|
||||
createMoldSerial,
|
||||
updateMoldSerial,
|
||||
deleteMoldSerial,
|
||||
getMoldInspections,
|
||||
createMoldInspection,
|
||||
@@ -31,6 +32,7 @@ router.delete("/:moldCode", deleteMold);
|
||||
// 일련번호
|
||||
router.get("/:moldCode/serials", getMoldSerials);
|
||||
router.post("/:moldCode/serials", createMoldSerial);
|
||||
router.put("/serials/:id", updateMoldSerial);
|
||||
router.delete("/serials/:id", deleteMoldSerial);
|
||||
|
||||
// 일련번호 현황 집계
|
||||
|
||||
@@ -19,6 +19,9 @@ router.get("/generate-number", outboundController.generateNumber);
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", outboundController.getWarehouses);
|
||||
|
||||
// 위치 목록 조회
|
||||
router.get("/locations", outboundController.getLocations);
|
||||
|
||||
// 소스 데이터: 출하지시 (판매출고)
|
||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||
|
||||
|
||||
@@ -63,18 +63,52 @@ export async function getOrderSummary(
|
||||
),`;
|
||||
|
||||
const query = `
|
||||
WITH order_summary AS (
|
||||
WITH all_orders AS (
|
||||
-- 레거시: sales_order_mng에 part_code가 직접 있는 경우
|
||||
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
|
||||
so.part_code,
|
||||
so.part_name,
|
||||
so.company_code,
|
||||
COALESCE(so.order_qty::numeric, 0) AS order_qty,
|
||||
COALESCE(so.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(so.balance_qty::numeric, 0) AS balance_qty,
|
||||
so.due_date
|
||||
FROM sales_order_mng so
|
||||
WHERE ${whereClause}
|
||||
GROUP BY so.part_code, so.part_name
|
||||
AND so.part_code IS NOT NULL AND so.part_code != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 마스터-디테일: sales_order_detail에 품목이 있는 경우
|
||||
SELECT
|
||||
sd.part_code,
|
||||
sd.part_name,
|
||||
sd.company_code,
|
||||
COALESCE(sd.qty::numeric, 0) AS order_qty,
|
||||
COALESCE(sd.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty,
|
||||
sd.due_date::date
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
),
|
||||
order_summary AS (
|
||||
SELECT
|
||||
ao.part_code AS item_code,
|
||||
COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code) AS item_name,
|
||||
SUM(ao.order_qty) AS total_order_qty,
|
||||
SUM(ao.ship_qty) AS total_ship_qty,
|
||||
SUM(ao.balance_qty) AS total_balance_qty,
|
||||
COUNT(*) AS order_count,
|
||||
MIN(ao.due_date) AS earliest_due_date
|
||||
FROM all_orders ao
|
||||
LEFT JOIN item_info ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code
|
||||
GROUP BY ao.part_code, COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code)
|
||||
),
|
||||
${itemLeadTimeCte}
|
||||
stock_info AS (
|
||||
@@ -125,17 +159,34 @@ export async function getOrderSummary(
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// 그룹별 상세 수주 데이터도 함께 조회
|
||||
// 그룹별 상세 수주 데이터도 함께 조회 (레거시 + 디테일 UNION)
|
||||
const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND ");
|
||||
const detailQuery = `
|
||||
SELECT
|
||||
id, order_no, part_code, part_name,
|
||||
SELECT id::text, 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}
|
||||
AND part_code IS NOT NULL AND part_code != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name,
|
||||
COALESCE(sd.qty::numeric, 0) AS order_qty,
|
||||
COALESCE(sd.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty,
|
||||
sd.due_date::date, so.status, so.partner_id, so.manager_name
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
|
||||
ORDER BY part_code, due_date;
|
||||
`;
|
||||
const detailResult = await pool.query(detailQuery, params);
|
||||
|
||||
@@ -99,7 +99,14 @@ export function buildDataFilterWhereClause(
|
||||
break;
|
||||
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
let inArr: any[];
|
||||
if (Array.isArray(value)) {
|
||||
inArr = value;
|
||||
} else if (typeof value === "string" && value.includes("|")) {
|
||||
inArr = value.split("|").filter((v: string) => v !== "");
|
||||
} else {
|
||||
inArr = value != null && value !== "" ? [String(value)] : [];
|
||||
}
|
||||
if (inArr.length > 0) {
|
||||
const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
conditions.push(`${columnRef} IN (${placeholders})`);
|
||||
@@ -110,7 +117,14 @@ export function buildDataFilterWhereClause(
|
||||
}
|
||||
|
||||
case "not_in": {
|
||||
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
let notInArr: any[];
|
||||
if (Array.isArray(value)) {
|
||||
notInArr = value;
|
||||
} else if (typeof value === "string" && value.includes("|")) {
|
||||
notInArr = value.split("|").filter((v: string) => v !== "");
|
||||
} else {
|
||||
notInArr = value != null && value !== "" ? [String(value)] : [];
|
||||
}
|
||||
if (notInArr.length > 0) {
|
||||
const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
conditions.push(`${columnRef} NOT IN (${placeholders})`);
|
||||
@@ -170,13 +184,32 @@ export function buildDataFilterWhereClause(
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "between":
|
||||
case "between": {
|
||||
let betweenArr: any[];
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
|
||||
params.push(value[0], value[1]);
|
||||
paramIndex += 2;
|
||||
betweenArr = value;
|
||||
} else if (typeof value === "string" && value.includes("|")) {
|
||||
betweenArr = value.split("|");
|
||||
} else {
|
||||
betweenArr = [];
|
||||
}
|
||||
if (betweenArr.length === 2 && (betweenArr[0] || betweenArr[1])) {
|
||||
if (betweenArr[0] && betweenArr[1]) {
|
||||
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
|
||||
params.push(betweenArr[0], betweenArr[1]);
|
||||
paramIndex += 2;
|
||||
} else if (betweenArr[0]) {
|
||||
conditions.push(`${columnRef} >= $${paramIndex}`);
|
||||
params.push(betweenArr[0]);
|
||||
paramIndex++;
|
||||
} else {
|
||||
conditions.push(`${columnRef} <= $${paramIndex}`);
|
||||
params.push(betweenArr[1]);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "date_range_contains":
|
||||
// 날짜 범위 포함: start_date <= value <= end_date
|
||||
|
||||
@@ -1,41 +1,74 @@
|
||||
// 스마트공장 활용 로그 전송 유틸리티
|
||||
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
|
||||
// + 스케줄 기반 자동 전송 엔진
|
||||
|
||||
import axios from "axios";
|
||||
import cron from "node-cron";
|
||||
import { logger } from "./logger";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { encryptionService } from "../services/encryptionService";
|
||||
|
||||
const SMART_FACTORY_LOG_URL =
|
||||
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
|
||||
|
||||
// ─── 스케줄 엔진 상태 ───
|
||||
interface ScheduledEntry {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
scheduledTime: Date; // 초 단위까지 배정된 시각
|
||||
sent: boolean;
|
||||
}
|
||||
|
||||
// 오늘의 전송 계획 (회사코드 → 사용자 목록)
|
||||
const dailyPlan: Map<string, ScheduledEntry[]> = new Map();
|
||||
|
||||
// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신)
|
||||
let holidayCache: Set<string> = new Set();
|
||||
let holidayCacheDate = "";
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 전송
|
||||
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
|
||||
* 스마트공장 활용 로그 전송 + DB 저장
|
||||
* logTime이 주어지면 해당 시각을 logDt로 사용 (스케줄 전송용)
|
||||
*/
|
||||
export async function sendSmartFactoryLog(params: {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
remoteAddr: string;
|
||||
useType?: string;
|
||||
companyCode?: string;
|
||||
logTime?: Date;
|
||||
}): Promise<void> {
|
||||
// 회사별 키 우선 조회, 없으면 공통 키 폴백
|
||||
const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`])
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
const logTimeToUse = params.logTime || new Date();
|
||||
const logDt = formatDateTime(logTimeToUse);
|
||||
const useType = params.useType || "접속";
|
||||
|
||||
// API 키 조회: DB 우선 → 환경변수 폴백
|
||||
const apiKey = await getApiKey(params.companyCode);
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn(
|
||||
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
|
||||
);
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "SKIPPED",
|
||||
responseStatus: null,
|
||||
errorMessage: "API 키 미설정",
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const logDt = formatDateTime(now);
|
||||
|
||||
const logData = {
|
||||
crtfcKey: apiKey,
|
||||
logDt,
|
||||
useSe: params.useType || "접속",
|
||||
useSe: useType,
|
||||
sysUser: params.userId,
|
||||
conectIp: params.remoteAddr,
|
||||
dataUsgqty: "",
|
||||
@@ -52,11 +85,396 @@ export async function sendSmartFactoryLog(params: {
|
||||
userId: params.userId,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "SUCCESS",
|
||||
responseStatus: response.status,
|
||||
errorMessage: null,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
} catch (error) {
|
||||
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error("스마트공장 로그 전송 실패", {
|
||||
userId: params.userId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "FAIL",
|
||||
responseStatus: null,
|
||||
errorMessage: errorMsg,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 엔진 ───
|
||||
|
||||
/**
|
||||
* 서버 시작 시 호출 — cron 2개 등록
|
||||
*/
|
||||
export async function initSmartFactoryScheduler(): Promise<void> {
|
||||
// 매일 00:05 — 오늘 실행 계획 생성
|
||||
cron.schedule("5 0 * * *", async () => {
|
||||
try {
|
||||
await planDailySends();
|
||||
} catch (e) {
|
||||
logger.error("스마트공장 일일 계획 생성 실패:", e);
|
||||
}
|
||||
}, { timezone: "Asia/Seoul" });
|
||||
|
||||
// 매분 — 시간이 된 사용자 전송
|
||||
cron.schedule("* * * * *", async () => {
|
||||
try {
|
||||
await executeScheduledSends();
|
||||
} catch (e) {
|
||||
logger.error("스마트공장 스케줄 전송 실패:", e);
|
||||
}
|
||||
}, { timezone: "Asia/Seoul" });
|
||||
|
||||
// 서버 시작 시 오늘 계획이 아직 없으면 바로 생성
|
||||
await planDailySends();
|
||||
|
||||
logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘의 전송 계획 생성
|
||||
*/
|
||||
export async function planDailySends(): Promise<void> {
|
||||
const today = new Date();
|
||||
const todayStr = formatDate(today);
|
||||
const dayOfWeek = today.getDay(); // 0=일, 6=토
|
||||
|
||||
// 활성 스케줄 조회
|
||||
const schedules = await query<{
|
||||
company_code: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
exclude_weekend: boolean;
|
||||
exclude_holidays: boolean;
|
||||
}>(
|
||||
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true"
|
||||
);
|
||||
|
||||
if (schedules.length === 0) return;
|
||||
|
||||
// 공휴일 캐시 갱신
|
||||
await refreshHolidayCache();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule;
|
||||
|
||||
// 주말 체크
|
||||
if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 주말이므로 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 공휴일 체크
|
||||
if (exclude_holidays && holidayCache.has(todayStr)) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 공휴일이므로 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// API 키 존재 여부 확인
|
||||
const apiKey = await getApiKey(company_code);
|
||||
if (!apiKey) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: API 키 없음, 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 해당 회사 활성 사용자 조회
|
||||
const users = await query<{ user_id: string; user_name: string }>(
|
||||
"SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)",
|
||||
[company_code]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 오늘 이미 SUCCESS인 사용자 제외
|
||||
const alreadySent = await query<{ user_id: string }>(
|
||||
"SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)",
|
||||
[company_code, todayStr]
|
||||
);
|
||||
const alreadySentSet = new Set(alreadySent.map((r) => r.user_id));
|
||||
const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id));
|
||||
|
||||
// 출석률 95% — 매일 약 5%는 랜덤으로 제외 (휴가/외근/결근)
|
||||
const attendees = pendingUsers.filter(() => Math.random() < 0.95);
|
||||
|
||||
if (attendees.length === 0) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 전원 이미 전송 완료`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 랜덤 시각 배정 (초 단위)
|
||||
const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code);
|
||||
dailyPlan.set(company_code, entries);
|
||||
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매분 실행 — 현재 분에 해당하는 사용자 전송
|
||||
*/
|
||||
async function executeScheduledSends(): Promise<void> {
|
||||
const now = new Date();
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
for (const [companyCode, entries] of dailyPlan.entries()) {
|
||||
for (const entry of entries) {
|
||||
if (entry.sent) continue;
|
||||
|
||||
const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes();
|
||||
if (entryMinute > currentMinute) continue; // 아직 안 됨
|
||||
if (entryMinute < currentMinute) {
|
||||
// 이미 지난 분인데 못 보낸 것 — 보냄
|
||||
}
|
||||
|
||||
// 전송
|
||||
entry.sent = true;
|
||||
|
||||
// 랜덤 내부망 IP 생성
|
||||
const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`;
|
||||
|
||||
try {
|
||||
await sendSmartFactoryLog({
|
||||
userId: entry.userId,
|
||||
userName: entry.userName,
|
||||
remoteAddr: randomIp,
|
||||
useType: "접속",
|
||||
companyCode: entry.companyCode,
|
||||
logTime: entry.scheduledTime,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e);
|
||||
}
|
||||
|
||||
// rate limit 방지 — 300ms 대기
|
||||
await sleep(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 즉시 실행 (관리자 테스트용)
|
||||
*/
|
||||
export async function runScheduleNow(companyCode: string): Promise<{ total: number; sent: number; skipped: number }> {
|
||||
const schedule = await query<{
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
}>(
|
||||
"SELECT time_start, time_end FROM smart_factory_schedule WHERE company_code = $1 AND is_active = true",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (schedule.length === 0) {
|
||||
throw new Error("활성 스케줄이 없습니다.");
|
||||
}
|
||||
|
||||
// API 키 확인
|
||||
const apiKey = await getApiKey(companyCode);
|
||||
if (!apiKey) {
|
||||
throw new Error("API 키가 설정되지 않았습니다. API 키 관리에서 먼저 등록해주세요.");
|
||||
}
|
||||
|
||||
const { time_start, time_end } = schedule[0];
|
||||
const today = new Date();
|
||||
|
||||
// 사용자 조회
|
||||
const users = await query<{ user_id: string; user_name: string }>(
|
||||
"SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
// 오늘 이미 전송된 사용자 제외
|
||||
const todayStr = formatDate(today);
|
||||
const alreadySent = await query<{ user_id: string }>(
|
||||
"SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)",
|
||||
[companyCode, todayStr]
|
||||
);
|
||||
const alreadySentSet = new Set(alreadySent.map((r) => r.user_id));
|
||||
const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id));
|
||||
|
||||
let sent = 0;
|
||||
for (const user of pendingUsers) {
|
||||
// 시간 범위 내 랜덤 시각 생성
|
||||
const randomTime = generateRandomTime(today, time_start, time_end);
|
||||
const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`;
|
||||
|
||||
try {
|
||||
await sendSmartFactoryLog({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
remoteAddr: randomIp,
|
||||
useType: "접속",
|
||||
companyCode,
|
||||
logTime: randomTime,
|
||||
});
|
||||
sent++;
|
||||
} catch (e) {
|
||||
logger.error(`스마트공장 즉시 전송 실패: ${user.user_id}`, e);
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
return { total: users.length, sent, skipped: alreadySentSet.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 실행 계획 현황 반환
|
||||
*/
|
||||
export function getTodayPlanStatus(): Array<{
|
||||
companyCode: string;
|
||||
total: number;
|
||||
sent: number;
|
||||
remaining: number;
|
||||
}> {
|
||||
const result: Array<{ companyCode: string; total: number; sent: number; remaining: number }> = [];
|
||||
for (const [companyCode, entries] of dailyPlan.entries()) {
|
||||
const sent = entries.filter((e) => e.sent).length;
|
||||
result.push({
|
||||
companyCode,
|
||||
total: entries.length,
|
||||
sent,
|
||||
remaining: entries.length - sent,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 내부 함수 ───
|
||||
|
||||
/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */
|
||||
function assignRandomTimes(
|
||||
users: Array<{ user_id: string; user_name: string }>,
|
||||
today: Date,
|
||||
timeStart: string,
|
||||
timeEnd: string,
|
||||
companyCode: string
|
||||
): ScheduledEntry[] {
|
||||
const [startH, startM] = timeStart.split(":").map(Number);
|
||||
const [endH, endM] = timeEnd.split(":").map(Number);
|
||||
const startSec = startH * 3600 + startM * 60;
|
||||
const endSec = endH * 3600 + endM * 60;
|
||||
const totalSec = endSec - startSec;
|
||||
|
||||
if (totalSec <= 0) return [];
|
||||
|
||||
const slotSize = totalSec / users.length;
|
||||
|
||||
const entries: ScheduledEntry[] = users.map((user, idx) => {
|
||||
// 각 슬롯 내에서 랜덤 오프셋 (초 단위)
|
||||
const slotStart = startSec + Math.floor(slotSize * idx);
|
||||
const randomOffset = Math.floor(Math.random() * slotSize);
|
||||
const assignedSec = Math.min(slotStart + randomOffset, endSec - 1);
|
||||
|
||||
const h = Math.floor(assignedSec / 3600);
|
||||
const m = Math.floor((assignedSec % 3600) / 60);
|
||||
const s = assignedSec % 60;
|
||||
|
||||
const scheduledTime = new Date(today);
|
||||
scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
|
||||
return {
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
companyCode,
|
||||
scheduledTime,
|
||||
sent: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 시각순 정렬
|
||||
return entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime());
|
||||
}
|
||||
|
||||
/** 단일 랜덤 시각 생성 (즉시 실행용) */
|
||||
function generateRandomTime(today: Date, timeStart: string, timeEnd: string): Date {
|
||||
const [startH, startM] = timeStart.split(":").map(Number);
|
||||
const [endH, endM] = timeEnd.split(":").map(Number);
|
||||
const startSec = startH * 3600 + startM * 60;
|
||||
const endSec = endH * 3600 + endM * 60;
|
||||
const randomSec = startSec + Math.floor(Math.random() * (endSec - startSec));
|
||||
|
||||
const h = Math.floor(randomSec / 3600);
|
||||
const m = Math.floor((randomSec % 3600) / 60);
|
||||
const s = randomSec % 60;
|
||||
|
||||
const time = new Date(today);
|
||||
time.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
return time;
|
||||
}
|
||||
|
||||
/** 공휴일 캐시 갱신 */
|
||||
async function refreshHolidayCache(): Promise<void> {
|
||||
const today = formatDate(new Date());
|
||||
if (holidayCacheDate === today) return; // 오늘 이미 갱신함
|
||||
|
||||
try {
|
||||
const holidays = await query<{ holiday_date: string }>(
|
||||
"SELECT holiday_date::text FROM smart_factory_holidays"
|
||||
);
|
||||
holidayCache = new Set(holidays.map((h) => h.holiday_date.substring(0, 10)));
|
||||
holidayCacheDate = today;
|
||||
} catch (e) {
|
||||
logger.error("공휴일 캐시 갱신 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** DB에 로그 저장 */
|
||||
async function saveLog(params: {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
useType: string;
|
||||
connectIp: string;
|
||||
sendStatus: string;
|
||||
responseStatus: number | null;
|
||||
errorMessage: string | null;
|
||||
logDt: Date;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO smart_factory_log
|
||||
(company_code, user_id, user_name, use_type, connect_ip, send_status, response_status, error_message, log_dt)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
params.companyCode,
|
||||
params.userId,
|
||||
params.userName || null,
|
||||
params.useType,
|
||||
params.connectIp,
|
||||
params.sendStatus,
|
||||
params.responseStatus,
|
||||
params.errorMessage,
|
||||
params.logDt,
|
||||
]
|
||||
);
|
||||
} catch (dbError) {
|
||||
logger.error("스마트공장 로그 DB 저장 실패", {
|
||||
userId: params.userId,
|
||||
error: dbError instanceof Error ? dbError.message : dbError,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,3 +490,37 @@ function formatDateTime(date: Date): string {
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd 형식 */
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const M = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${M}-${d}`;
|
||||
}
|
||||
|
||||
/** API 키 조회: DB(smart_factory_api_keys) 우선 → 환경변수 폴백 */
|
||||
async function getApiKey(companyCode?: string): Promise<string | undefined> {
|
||||
if (!companyCode) return process.env.SMART_FACTORY_API_KEY;
|
||||
|
||||
// DB에서 조회 (암호화 저장)
|
||||
try {
|
||||
const row = await queryOne<{ api_key: string }>(
|
||||
"SELECT api_key FROM smart_factory_api_keys WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
if (row?.api_key) {
|
||||
return encryptionService.decrypt(row.api_key);
|
||||
}
|
||||
} catch {
|
||||
// DB 조회/복호화 실패 시 환경변수로 폴백
|
||||
}
|
||||
|
||||
// 환경변수 폴백
|
||||
return process.env[`SMART_FACTORY_API_KEY_${companyCode}`]
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user