fix: handle visit request Excel form upload

This commit is contained in:
unknown
2026-07-27 15:08:36 +09:00
parent eaecfae330
commit 6c511bf355
8 changed files with 231 additions and 16 deletions

View File

@@ -4,6 +4,8 @@ import multer from 'multer';
import ExcelJS from 'exceljs';
import QRCode from 'qrcode';
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js';
import { env } from '../config/env.js';
@@ -61,7 +63,14 @@ interface AccessRow {
check_out_at: string | Date | null;
}
interface ReportVisitRow extends VisitRow {
check_in_at: string | Date | null;
check_out_at: string | Date | null;
inside: boolean;
}
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } });
const visitRequestTemplatePath = path.resolve(process.cwd(), 'docs', 'form_sample.xlsx');
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
@@ -107,6 +116,24 @@ function toVisit(row: VisitRow) {
};
}
function visitStatusLabel(status: string): string {
switch (status) {
case 'DRAFT': return '임시저장';
case 'PENDING': return '승인대기';
case 'APPROVED': return '승인완료';
case 'REJECTED': return '반려';
case 'CANCELLED': return '취소';
case 'EXPIRED': return '만료';
default: return status;
}
}
function reportStatusLabel(row: Pick<ReportVisitRow, 'status' | 'inside' | 'check_out_at'>): string {
if (row.inside) return '재실중';
if (row.check_out_at) return '퇴실';
return visitStatusLabel(row.status);
}
function required(value: unknown, label: string): string {
const text = String(value ?? '').trim();
if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`);
@@ -359,7 +386,14 @@ function parseVisitDate(row: ExcelJS.Row, index: number, label: string): string
.replace(/-+/g, '-')
.replace(/-$/, '');
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (!match) throw new ApiError(400, `${label} 형식이 올바르지 않습니다. 예: 2026-07-08 또는 2026.7.8`);
if (!match) {
const koreanMonthDay = normalized.match(/^(\d{1,2})\uC6D4(\d{1,2})\uC77C/);
if (koreanMonthDay) {
const year = new Date().getFullYear();
return `${year}-${pad2(Number(koreanMonthDay[1]))}-${pad2(Number(koreanMonthDay[2]))}`;
}
throw new ApiError(400, `${label} 형식이 올바르지 않습니다. 예: 2026-07-08 또는 2026.7.8`);
}
return `${match[1]}-${pad2(Number(match[2]))}-${pad2(Number(match[3]))}`;
}
@@ -399,6 +433,41 @@ function isVisitUploadRowEmpty(row: ExcelJS.Row): boolean {
return [2, 4, 5, 7, 9].every((index) => !cellText(row, index));
}
function compactCellText(row: ExcelJS.Row, index: number): string {
return cellText(row, index).replace(/\s+/g, '');
}
function isVisitUploadNonDataRow(row: ExcelJS.Row): boolean {
const sequence = compactCellText(row, 1);
const purpose = compactCellText(row, 2);
const date = compactCellText(row, 5);
const name = compactCellText(row, 7);
return (
sequence.includes('\uC21C\uBC88') ||
purpose.includes('\uCD9C\uC785\uAC1C\uC694') ||
date.includes('\uCD9C\uC785\uAC1C\uC694') ||
date.includes('\uCD9C\uC785\uC77C\uC790') ||
name.includes('\uCD9C\uC785\uC790\uC815\uBCF4') ||
name.includes('\uC774\uB984')
);
}
function findVisitUploadStartRow(sheet: ExcelJS.Worksheet): number {
for (let rowNumber = 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const dateHeader = compactCellText(row, 5);
const nameHeader = compactCellText(row, 7);
if (
dateHeader.includes('\uCD9C\uC785\uC77C\uC790') &&
nameHeader.includes('\uC774\uB984')
) {
return rowNumber + 1;
}
}
return 3;
}
async function workbookFromUpload(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
if (!file) throw new ApiError(400, '업로드할 파일을 선택하세요.');
const workbook = new ExcelJS.Workbook();
@@ -411,10 +480,12 @@ async function importVisitWorkbook(dbQuery: QueryFn, actor: UserRow, file?: Expr
const sheet = workbook.getWorksheet('방문자명단') ?? workbook.worksheets[0];
if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.');
const result = { totalRows: 0, successCount: 0, errors: [] as string[], success: false };
const startRow = findVisitUploadStartRow(sheet);
for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) {
for (let rowNumber = startRow; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
if (isVisitUploadRowEmpty(row)) continue;
if (isVisitUploadNonDataRow(row)) continue;
result.totalRows += 1;
try {
const date = parseVisitDate(row, 5, '출입일자');
@@ -551,6 +622,14 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
}));
router.get('/visit-requests/template', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
if (!existsSync(visitRequestTemplatePath)) {
throw new ApiError(404, '엑셀 업로드 양식을 찾을 수 없습니다.');
}
res.download(visitRequestTemplatePath, 'visit-request-template.xlsx');
}));
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visit = await visitById(dbQuery, Number(req.params.id));
@@ -879,17 +958,129 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
router.get('/reports/visits', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]));
const result = await dbQuery<ReportVisitRow>(
`
SELECT
vr.id,
v.name AS visitor_name,
v.company,
v.contact,
v.email,
v.vehicle_no,
h.id AS host_id,
h.full_name AS host_name,
h.department AS host_department,
vr.zone_name,
vr.purpose,
vr.purpose_code,
vr.purpose_detail,
vr.work_name,
vr.control_name,
vr.control_team,
vr.control_contact,
vr.watcher1_name,
vr.watcher1_team,
vr.watcher1_contact,
vr.watcher2_name,
vr.watcher2_team,
vr.watcher2_contact,
vr.visit_from,
vr.visit_to,
vr.status,
vr.qr_token,
vr.created_at,
acc.check_in_at,
acc.check_out_at,
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), FALSE) AS inside
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
LEFT JOIN LATERAL (
SELECT
max(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at
FROM access_events ae
WHERE ae.visit_request_id = vr.id
) acc ON TRUE
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
ORDER BY vr.created_at DESC
`,
[req.query.from, req.query.to],
);
ok(res, result.rows.map((row) => ({
...toVisit(row),
checkInAt: toIso(row.check_in_at),
checkOutAt: toIso(row.check_out_at),
inside: Boolean(row.inside),
reportStatusLabel: reportStatusLabel(row),
})));
}));
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]);
const visits = await dbQuery<ReportVisitRow>(
`
SELECT
vr.id,
v.name AS visitor_name,
v.company,
v.contact,
v.email,
v.vehicle_no,
h.id AS host_id,
h.full_name AS host_name,
h.department AS host_department,
vr.zone_name,
vr.purpose,
vr.purpose_code,
vr.purpose_detail,
vr.work_name,
vr.control_name,
vr.control_team,
vr.control_contact,
vr.watcher1_name,
vr.watcher1_team,
vr.watcher1_contact,
vr.watcher2_name,
vr.watcher2_team,
vr.watcher2_contact,
vr.visit_from,
vr.visit_to,
vr.status,
vr.qr_token,
vr.created_at,
acc.check_in_at,
acc.check_out_at,
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), FALSE) AS inside
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
LEFT JOIN LATERAL (
SELECT
max(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at
FROM access_events ae
WHERE ae.visit_request_id = vr.id
) acc ON TRUE
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
ORDER BY vr.created_at DESC
`,
[req.query.from, req.query.to],
);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('visits');
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
for (const visit of visits) {
sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]);
for (const visit of visits.rows) {
sheet.addRow([
visit.visitor_name,
visit.company ?? '',
visit.zone_name ?? '',
visit.purpose,
visit.work_name ?? '',
reportStatusLabel(visit),
toIso(visit.visit_from),
toIso(visit.check_out_at) ?? toIso(visit.visit_to),
]);
}
sheet.getRow(1).font = { bold: true };
const buffer = await workbook.xlsx.writeBuffer();