feat: enable visit request Excel upload in Node
This commit is contained in:
@@ -228,9 +228,150 @@ async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpos
|
||||
return { code: item.code, name: text, detail: text, display: text };
|
||||
}
|
||||
const text = required(purpose, '출입 목적');
|
||||
const matched = await dbQuery<{ code: string; name: string }>(
|
||||
'SELECT code, name FROM purpose_codes WHERE active = TRUE AND (upper(code) = upper($1) OR name = $1)',
|
||||
[text],
|
||||
);
|
||||
if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name };
|
||||
return { code: null, name: text, detail: null, display: text };
|
||||
}
|
||||
|
||||
function cellText(row: ExcelJS.Row, index: number): string {
|
||||
const value = row.getCell(index).value;
|
||||
if (value == null) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim();
|
||||
if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim();
|
||||
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('').trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function requiredCell(row: ExcelJS.Row, index: number, label: string): string {
|
||||
const value = cellText(row, index);
|
||||
if (!value) throw new ApiError(400, `${label}은(는) 필수입니다.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function excelSerialToDate(serial: number): Date {
|
||||
return new Date(Math.round((serial - 25569) * 86400 * 1000));
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function datePart(date: Date): string {
|
||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function parseVisitDate(row: ExcelJS.Row, index: number, label: string): string {
|
||||
const value = row.getCell(index).value;
|
||||
if (value instanceof Date) return datePart(value);
|
||||
if (typeof value === 'number') return datePart(excelSerialToDate(value));
|
||||
const text = requiredCell(row, index, label);
|
||||
const normalized = text
|
||||
.split('(')[0]
|
||||
.trim()
|
||||
.replace(/[./]/g, '-')
|
||||
.replace(/\s+/g, '')
|
||||
.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`);
|
||||
return `${match[1]}-${pad2(Number(match[2]))}-${pad2(Number(match[3]))}`;
|
||||
}
|
||||
|
||||
function secondsToTime(seconds: number): string {
|
||||
const normalized = ((Math.round(seconds) % 86400) + 86400) % 86400;
|
||||
const h = Math.floor(normalized / 3600);
|
||||
const m = Math.floor((normalized % 3600) / 60);
|
||||
const s = normalized % 60;
|
||||
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
|
||||
}
|
||||
|
||||
function parseVisitTime(row: ExcelJS.Row, index: number): string {
|
||||
const value = row.getCell(index).value;
|
||||
if (value instanceof Date) {
|
||||
return `${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`;
|
||||
}
|
||||
if (typeof value === 'number') return secondsToTime((value - Math.floor(value)) * 86400);
|
||||
const text = cellText(row, index);
|
||||
if (!text) return '00:00:00';
|
||||
const match = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (match) {
|
||||
const h = Number(match[1]);
|
||||
const m = Number(match[2]);
|
||||
const s = Number(match[3] ?? 0);
|
||||
if (h >= 0 && h <= 23 && m >= 0 && m <= 59 && s >= 0 && s <= 59) return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
|
||||
}
|
||||
const numeric = Number(text);
|
||||
if (Number.isFinite(numeric)) return secondsToTime((numeric - Math.floor(numeric)) * 86400);
|
||||
return '00:00:00';
|
||||
}
|
||||
|
||||
function parseRooms(raw: string): string[] {
|
||||
return raw.split(/[,;/]/).map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isVisitUploadRowEmpty(row: ExcelJS.Row): boolean {
|
||||
return [2, 4, 5, 7, 9].every((index) => !cellText(row, index));
|
||||
}
|
||||
|
||||
async function workbookFromUpload(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
|
||||
if (!file) throw new ApiError(400, '업로드할 파일을 선택하세요.');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
async function importVisitWorkbook(dbQuery: QueryFn, actor: UserRow, file?: Express.Multer.File) {
|
||||
const workbook = await workbookFromUpload(file);
|
||||
const sheet = workbook.getWorksheet('방문자명단') ?? workbook.worksheets[0];
|
||||
if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.');
|
||||
const result = { totalRows: 0, successCount: 0, errors: [] as string[], success: false };
|
||||
|
||||
for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (isVisitUploadRowEmpty(row)) continue;
|
||||
result.totalRows += 1;
|
||||
try {
|
||||
const date = parseVisitDate(row, 5, '출입일자');
|
||||
const time = parseVisitTime(row, 6);
|
||||
const rooms = parseRooms(requiredCell(row, 4, '장소'));
|
||||
if (rooms.length === 0) throw new ApiError(400, '장소는 필수입니다.');
|
||||
for (const room of rooms) {
|
||||
await createOneVisit(dbQuery, actor, {
|
||||
purpose: requiredCell(row, 2, '출입목적'),
|
||||
workName: cellText(row, 3) || undefined,
|
||||
visitorName: requiredCell(row, 7, '이름'),
|
||||
company: cellText(row, 8) || undefined,
|
||||
contact: requiredCell(row, 9, '연락처'),
|
||||
vehicleNo: cellText(row, 10) || undefined,
|
||||
controlName: cellText(row, 11) || undefined,
|
||||
controlTeam: cellText(row, 12) || undefined,
|
||||
controlContact: cellText(row, 13) || undefined,
|
||||
watcher1Name: cellText(row, 14) || undefined,
|
||||
watcher1Team: cellText(row, 15) || undefined,
|
||||
watcher1Contact: cellText(row, 16) || undefined,
|
||||
watcher2Name: cellText(row, 17) || undefined,
|
||||
watcher2Team: cellText(row, 18) || undefined,
|
||||
watcher2Contact: cellText(row, 19) || undefined,
|
||||
visitFrom: `${date}T${time}`,
|
||||
visitTo: `${date}T23:59:59`,
|
||||
}, room);
|
||||
}
|
||||
result.successCount += 1;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
result.errors.push(`${rowNumber}행: ${message}`);
|
||||
}
|
||||
}
|
||||
result.success = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<string, unknown>, zoneName: string) {
|
||||
const visitorName = required(body.visitorName, '방문자 이름');
|
||||
const contact = required(body.contact, '방문자 연락처');
|
||||
@@ -291,12 +432,12 @@ async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<str
|
||||
visitFrom,
|
||||
visitTo,
|
||||
qrToken,
|
||||
actor.full_name,
|
||||
actor.department,
|
||||
null,
|
||||
watcher.name,
|
||||
watcher.team,
|
||||
watcher.contact,
|
||||
body.controlName ? String(body.controlName).trim() : actor.full_name,
|
||||
body.controlTeam ? String(body.controlTeam).trim() : actor.department,
|
||||
body.controlContact ? String(body.controlContact).trim() : null,
|
||||
body.watcher1Name ? String(body.watcher1Name).trim() : watcher.name,
|
||||
body.watcher1Team ? String(body.watcher1Team).trim() : watcher.team,
|
||||
body.watcher1Contact ? String(body.watcher1Contact).trim() : watcher.contact,
|
||||
body.watcher2Name ? String(body.watcher2Name).trim() : null,
|
||||
body.watcher2Team ? String(body.watcher2Team).trim() : null,
|
||||
body.watcher2Contact ? String(body.watcher2Contact).trim() : null,
|
||||
@@ -357,8 +498,9 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
|
||||
ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!));
|
||||
}));
|
||||
|
||||
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => {
|
||||
ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false });
|
||||
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (req, res) => {
|
||||
const actor = await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await importVisitWorkbook(dbQuery, actor, req.file));
|
||||
}));
|
||||
|
||||
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user