diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index 0920923..9d0ddcc 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -751,3 +751,13 @@ - 판단: - Gitea push는 성공했지만 Coolify가 아직 새 커밋을 재배포하지 않음. - 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` 성공. diff --git a/server/routes/business.ts b/server/routes/business.ts index ef11336..7055c59 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -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 { + 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, 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 { - 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) => {