feat: enable visit request Excel upload in Node
This commit is contained in:
@@ -751,3 +751,13 @@
|
|||||||
- 판단:
|
- 판단:
|
||||||
- Gitea push는 성공했지만 Coolify가 아직 새 커밋을 재배포하지 않음.
|
- Gitea push는 성공했지만 Coolify가 아직 새 커밋을 재배포하지 않음.
|
||||||
- Public Repository 방식은 webhook 미설정 시 push만으로 자동 배포되지 않으므로 Coolify에서 수동 Redeploy 또는 Gitea webhook 설정 필요.
|
- Public Repository 방식은 webhook 미설정 시 push만으로 자동 배포되지 않으므로 Coolify에서 수동 Redeploy 또는 Gitea webhook 설정 필요.
|
||||||
|
|
||||||
|
- 2026-07-16 서버 배포 기능 보완:
|
||||||
|
- Node 배포본의 `/api/visit-requests/upload` 방문자 명단 엑셀 업로드 stub 제거.
|
||||||
|
- 기존 Spring `ExcelImportService` 기준으로 `방문자명단` 시트 3행부터 출입목적, 작업명, 장소, 출입일자, 출입시간, 방문자 정보, 통제담당자, 현장감시자 정보를 읽어 출입신청을 생성하도록 이관.
|
||||||
|
- 엑셀 출입목적 값이 시스템 출입목적 코드명/표시명과 일치하면 `purpose_code`로 정규화 저장하도록 보완.
|
||||||
|
- 엑셀에 출입통제담당자/현장감시자1 값이 있으면 해당 값을 우선 저장하고, 비어 있으면 기존처럼 로그인 사용자/시스템 설정값을 사용.
|
||||||
|
- 검증:
|
||||||
|
- `npm.cmd run typecheck` 성공.
|
||||||
|
- `npm.cmd run build` 성공.
|
||||||
|
- `npm.cmd test` 성공.
|
||||||
|
|||||||
@@ -228,9 +228,150 @@ async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpos
|
|||||||
return { code: item.code, name: text, detail: text, display: text };
|
return { code: item.code, name: text, detail: text, display: text };
|
||||||
}
|
}
|
||||||
const text = required(purpose, '출입 목적');
|
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 };
|
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) {
|
async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<string, unknown>, zoneName: string) {
|
||||||
const visitorName = required(body.visitorName, '방문자 이름');
|
const visitorName = required(body.visitorName, '방문자 이름');
|
||||||
const contact = required(body.contact, '방문자 연락처');
|
const contact = required(body.contact, '방문자 연락처');
|
||||||
@@ -291,12 +432,12 @@ async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<str
|
|||||||
visitFrom,
|
visitFrom,
|
||||||
visitTo,
|
visitTo,
|
||||||
qrToken,
|
qrToken,
|
||||||
actor.full_name,
|
body.controlName ? String(body.controlName).trim() : actor.full_name,
|
||||||
actor.department,
|
body.controlTeam ? String(body.controlTeam).trim() : actor.department,
|
||||||
null,
|
body.controlContact ? String(body.controlContact).trim() : null,
|
||||||
watcher.name,
|
body.watcher1Name ? String(body.watcher1Name).trim() : watcher.name,
|
||||||
watcher.team,
|
body.watcher1Team ? String(body.watcher1Team).trim() : watcher.team,
|
||||||
watcher.contact,
|
body.watcher1Contact ? String(body.watcher1Contact).trim() : watcher.contact,
|
||||||
body.watcher2Name ? String(body.watcher2Name).trim() : null,
|
body.watcher2Name ? String(body.watcher2Name).trim() : null,
|
||||||
body.watcher2Team ? String(body.watcher2Team).trim() : null,
|
body.watcher2Team ? String(body.watcher2Team).trim() : null,
|
||||||
body.watcher2Contact ? String(body.watcher2Contact).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)))!));
|
ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => {
|
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (req, res) => {
|
||||||
ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false });
|
const actor = await requireCurrentUser(dbQuery, req);
|
||||||
|
ok(res, await importVisitWorkbook(dbQuery, actor, req.file));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
|
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user