feat: prepare ACS deployment
This commit is contained in:
@@ -29,13 +29,18 @@ public class DataSeeder implements CommandLineRunner {
|
||||
private final ZoneRepository zoneRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
private static final String DEFAULT_PASSWORD = "ChangeMe123!";
|
||||
// 적용(운영 반영) 전까지 신속한 테스트를 위한 단축 계정. dev(비-prod)에서만 시드된다.
|
||||
// 운영 전환 시 이 시드는 무시되고 Flyway/CSV 시더가 실제 계정을 관리한다.
|
||||
private static final String TEST_PASSWORD = "1";
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
|
||||
seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
|
||||
seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST));
|
||||
seedUser("a", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
|
||||
seedUser("s", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
|
||||
seedUser("h", "홍길동", "개발1팀", Set.of(RoleType.HOST));
|
||||
|
||||
seedZone("LOBBY", "로비", 1);
|
||||
seedZone("OFFICE", "사무공간", 2);
|
||||
@@ -48,14 +53,14 @@ public class DataSeeder implements CommandLineRunner {
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD));
|
||||
user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD));
|
||||
user.setFullName(fullName);
|
||||
user.setDepartment(department);
|
||||
user.setEmail(username + "@itcenter.local");
|
||||
user.setRoles(roles);
|
||||
user.setMustChangePassword(true);
|
||||
user.setMustChangePassword(false); // 테스트 편의: 최초 로그인 시 비번변경 강제하지 않음
|
||||
userRepository.save(user);
|
||||
log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD);
|
||||
log.info("[seed] test user '{}' created (password: {})", username, TEST_PASSWORD);
|
||||
}
|
||||
|
||||
private void seedZone(String code, String name, int level) {
|
||||
|
||||
@@ -103,6 +103,8 @@ public class AuthController {
|
||||
principal.getId(),
|
||||
principal.getUsername(),
|
||||
principal.getFullName(),
|
||||
principal.getDepartment(),
|
||||
principal.getEmail(),
|
||||
roles,
|
||||
principal.isMustChangePassword());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.itcenter.acs.dto.VisitRequestCreateRequest;
|
||||
import com.itcenter.acs.dto.VisitRequestResponse;
|
||||
import com.itcenter.acs.entity.VisitRequest;
|
||||
import com.itcenter.acs.security.SecurityUtils;
|
||||
import com.itcenter.acs.service.ApprovalService;
|
||||
import com.itcenter.acs.service.ExcelImportService;
|
||||
import com.itcenter.acs.service.VisitRequestService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -31,13 +32,27 @@ public class VisitRequestController {
|
||||
|
||||
private final VisitRequestService visitRequestService;
|
||||
private final ExcelImportService excelImportService;
|
||||
private final ApprovalService approvalService;
|
||||
|
||||
/** Create a single pre-registration; current user becomes the host. */
|
||||
/**
|
||||
* Create pre-registration(s); current user becomes the host. One request is issued per
|
||||
* selected server room, so the response may contain more than one (e.g. 4층+5층전산실 → 2건).
|
||||
* When the registrant already holds approval authority (ADMIN/SECURITY), each request is
|
||||
* self-approved immediately (QR issued) — HOST registrations stay PENDING.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<VisitRequestResponse>> create(
|
||||
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> create(
|
||||
@Valid @RequestBody VisitRequestCreateRequest request) {
|
||||
VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId());
|
||||
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created)));
|
||||
Long userId = SecurityUtils.currentUserId();
|
||||
List<VisitRequest> created = visitRequestService.createRequests(request, userId);
|
||||
if (SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY")) {
|
||||
List<VisitRequest> approved = new java.util.ArrayList<>(created.size());
|
||||
for (VisitRequest vr : created) {
|
||||
approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인"));
|
||||
}
|
||||
created = approved;
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(toResponses(created)));
|
||||
}
|
||||
|
||||
/** ADMIN/SECURITY see all; HOST sees only their own requests. */
|
||||
|
||||
@@ -11,6 +11,8 @@ public class CurrentUserResponse {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String fullName;
|
||||
private String department;
|
||||
private String email;
|
||||
private Set<String> roles;
|
||||
private boolean mustChangePassword;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.itcenter.acs.dto;
|
||||
|
||||
import jakarta.validation.constraints.Future;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class VisitRequestCreateRequest {
|
||||
@@ -20,12 +20,40 @@ public class VisitRequestCreateRequest {
|
||||
private String email;
|
||||
private String vehicleNo;
|
||||
|
||||
/** Access zone label (fixed list value or "기타" free text). */
|
||||
private String zoneName;
|
||||
/**
|
||||
* Server rooms (전산실) selected via checkboxes, e.g. ["4층전산실", "5층전산실"].
|
||||
* Each selected room becomes its own visit request / QR pass.
|
||||
*/
|
||||
private List<String> serverRooms;
|
||||
|
||||
/**
|
||||
* Optional detail room (콤보박스, 기타 선택 시 자유 입력값). Auxiliary info — carried
|
||||
* alongside the server room on the zone label but never issues its own QR. When no
|
||||
* server room is selected, this becomes the sole access zone.
|
||||
*/
|
||||
private String roomZone;
|
||||
|
||||
@NotBlank(message = "출입 목적을 입력하세요.")
|
||||
private String purpose;
|
||||
|
||||
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
||||
private String workName;
|
||||
|
||||
// 출입통제담당자(본인) — 웹은 서버가 로그인 사용자로 채움. 엑셀은 파일값 사용.
|
||||
private String controlName;
|
||||
private String controlTeam;
|
||||
private String controlContact;
|
||||
|
||||
// 현장감시자1(고정) — 웹은 서버가 고정 상수로 채움. 엑셀은 파일값(비면 고정).
|
||||
private String watcher1Name;
|
||||
private String watcher1Team;
|
||||
private String watcher1Contact;
|
||||
|
||||
// 현장감시자2 — 담당자가 입력.
|
||||
private String watcher2Name;
|
||||
private String watcher2Team;
|
||||
private String watcher2Contact;
|
||||
|
||||
@NotNull(message = "방문 시작 일시를 입력하세요.")
|
||||
private LocalDateTime visitFrom;
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ public class VisitRequestResponse {
|
||||
private String hostDepartment;
|
||||
private String zoneName;
|
||||
private String purpose;
|
||||
private String workName;
|
||||
private String controlName;
|
||||
private String controlTeam;
|
||||
private String controlContact;
|
||||
private String watcher1Name;
|
||||
private String watcher1Team;
|
||||
private String watcher1Contact;
|
||||
private String watcher2Name;
|
||||
private String watcher2Team;
|
||||
private String watcher2Contact;
|
||||
private LocalDateTime visitFrom;
|
||||
private LocalDateTime visitTo;
|
||||
private String status;
|
||||
@@ -35,6 +45,16 @@ public class VisitRequestResponse {
|
||||
r.hostDepartment = vr.getHost().getDepartment();
|
||||
r.zoneName = vr.getZoneName();
|
||||
r.purpose = vr.getPurpose();
|
||||
r.workName = vr.getWorkName();
|
||||
r.controlName = vr.getControlName();
|
||||
r.controlTeam = vr.getControlTeam();
|
||||
r.controlContact = vr.getControlContact();
|
||||
r.watcher1Name = vr.getWatcher1Name();
|
||||
r.watcher1Team = vr.getWatcher1Team();
|
||||
r.watcher1Contact = vr.getWatcher1Contact();
|
||||
r.watcher2Name = vr.getWatcher2Name();
|
||||
r.watcher2Team = vr.getWatcher2Team();
|
||||
r.watcher2Contact = vr.getWatcher2Contact();
|
||||
r.visitFrom = vr.getVisitFrom();
|
||||
r.visitTo = vr.getVisitTo();
|
||||
r.status = vr.getStatus().name();
|
||||
|
||||
@@ -46,6 +46,34 @@ public class VisitRequest extends BaseEntity {
|
||||
@Column(nullable = false, length = 255)
|
||||
private String purpose;
|
||||
|
||||
/** 작업명 — concrete task detail, kept separate from the simple purpose category. */
|
||||
@Column(name = "work_name", length = 255)
|
||||
private String workName;
|
||||
|
||||
// ===== 출입통제담당자 (본인 = 등록한 내부 직원) 스냅샷 =====
|
||||
@Column(name = "control_name", length = 80)
|
||||
private String controlName;
|
||||
@Column(name = "control_team", length = 80)
|
||||
private String controlTeam;
|
||||
@Column(name = "control_contact", length = 60)
|
||||
private String controlContact;
|
||||
|
||||
// ===== 현장감시자1 (고정) =====
|
||||
@Column(name = "watcher1_name", length = 80)
|
||||
private String watcher1Name;
|
||||
@Column(name = "watcher1_team", length = 80)
|
||||
private String watcher1Team;
|
||||
@Column(name = "watcher1_contact", length = 60)
|
||||
private String watcher1Contact;
|
||||
|
||||
// ===== 현장감시자2 (담당자가 입력) =====
|
||||
@Column(name = "watcher2_name", length = 80)
|
||||
private String watcher2Name;
|
||||
@Column(name = "watcher2_team", length = 80)
|
||||
private String watcher2Team;
|
||||
@Column(name = "watcher2_contact", length = 60)
|
||||
private String watcher2Contact;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime visitFrom;
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ public class UserPrincipal implements UserDetails {
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final String fullName;
|
||||
private final String department;
|
||||
private final String email;
|
||||
private final boolean mustChangePassword;
|
||||
private final boolean enabled;
|
||||
private final boolean locked;
|
||||
@@ -31,6 +33,8 @@ public class UserPrincipal implements UserDetails {
|
||||
this.username = user.getUsername();
|
||||
this.password = user.getPasswordHash();
|
||||
this.fullName = user.getFullName();
|
||||
this.department = user.getDepartment();
|
||||
this.email = user.getEmail();
|
||||
this.mustChangePassword = user.isMustChangePassword();
|
||||
this.enabled = user.isEnabled();
|
||||
this.locked = user.isLocked();
|
||||
|
||||
@@ -15,16 +15,24 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bulk-imports visit requests from an .xlsx file.
|
||||
* Columns (row 1 = header, skipped):
|
||||
* 0 visitorName | 1 company | 2 contact | 3 email | 4 vehicleNo
|
||||
* 5 zoneName | 6 purpose | 7 visitFrom | 8 visitTo
|
||||
* Dates accept Excel date cells or "yyyy-MM-dd HH:mm" / "yyyy-MM-dd" text.
|
||||
* Bulk-imports visit requests from the corporate 방문자명단 .xlsx template
|
||||
* (itcas_visitor_template.xlsx). The data sheet is "방문자명단"; the first two rows are
|
||||
* group + column headers and are skipped. Columns (0-based):
|
||||
* 0 순번 | 1 출입목적 | 2 작업명 | 3 장소 | 4 출입일자 | 5 출입시간 |
|
||||
* 6 이름 | 7 소속 | 8 연락처(휴대전화) | 9 차량번호 |
|
||||
* 10 직원명 | 11 담당팀명 | 12 연락처 (출입통제담당자) |
|
||||
* 13 이름 | 14 소속 | 15 연락처 (현장감시자1) |
|
||||
* 16 이름 | 17 소속 | 18 연락처 (현장감시자2)
|
||||
* Each row is one 장소 → one request/QR ("4층전산실과 5층전산실은 개별 행으로 작성"). Only the
|
||||
* start date+time are captured; visitTo defaults to 23:59:59 of that day (actual entry/exit
|
||||
* are tracked by check-in/out). 출입일자 accepts Excel date cells or "yyyy.M.d(요일)" /
|
||||
* "yyyy-MM-dd" text; 출입시간 accepts Excel time cells, "HH:mm" text, or a day fraction.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -32,7 +40,9 @@ public class ExcelImportService {
|
||||
|
||||
private final VisitRequestService visitRequestService;
|
||||
|
||||
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]");
|
||||
private static final String DATA_SHEET = "방문자명단";
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-M-d");
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("H:mm[:ss]");
|
||||
|
||||
// Not @Transactional: each row imports in its own transaction (create() is
|
||||
// @Transactional), so a duplicate/invalid row fails independently without
|
||||
@@ -42,10 +52,13 @@ public class ExcelImportService {
|
||||
int dataRows = 0;
|
||||
|
||||
try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Sheet sheet = workbook.getSheet(DATA_SHEET);
|
||||
if (sheet == null) {
|
||||
sheet = workbook.getSheetAt(0);
|
||||
}
|
||||
for (Row row : sheet) {
|
||||
if (row.getRowNum() == 0) {
|
||||
continue; // header
|
||||
if (row.getRowNum() <= 1) {
|
||||
continue; // 그룹 헤더 + 컬럼 헤더
|
||||
}
|
||||
if (isEmptyRow(row)) {
|
||||
continue;
|
||||
@@ -53,7 +66,7 @@ public class ExcelImportService {
|
||||
dataRows++;
|
||||
try {
|
||||
VisitRequestCreateRequest req = parseRow(row);
|
||||
visitRequestService.create(req, hostUserId);
|
||||
visitRequestService.createRequests(req, hostUserId);
|
||||
result.setSuccessCount(result.getSuccessCount() + 1);
|
||||
} catch (Exception e) {
|
||||
result.getErrors().add("행 " + (row.getRowNum() + 1) + ": " + e.getMessage());
|
||||
@@ -66,20 +79,57 @@ public class ExcelImportService {
|
||||
|
||||
private VisitRequestCreateRequest parseRow(Row row) {
|
||||
VisitRequestCreateRequest req = new VisitRequestCreateRequest();
|
||||
req.setVisitorName(requireString(row.getCell(0), "방문자 이름"));
|
||||
req.setCompany(getString(row.getCell(1)));
|
||||
req.setContact(requireString(row.getCell(2), "연락처"));
|
||||
req.setEmail(getString(row.getCell(3)));
|
||||
req.setVehicleNo(getString(row.getCell(4)));
|
||||
req.setZoneName(getString(row.getCell(5)));
|
||||
req.setPurpose(requireString(row.getCell(6), "출입 목적"));
|
||||
req.setVisitFrom(requireDateTime(row.getCell(7), "출입 일시"));
|
||||
req.setVisitTo(requireDateTime(row.getCell(8), "퇴실 일시"));
|
||||
req.setPurpose(requireString(row.getCell(1), "출입목적"));
|
||||
req.setWorkName(getString(row.getCell(2)));
|
||||
req.setServerRooms(parseServerRooms(requireString(row.getCell(3), "장소")));
|
||||
|
||||
LocalDate date = requireDate(row.getCell(4), "출입일자");
|
||||
LocalTime time = parseTime(row.getCell(5));
|
||||
req.setVisitFrom(date.atTime(time != null ? time : LocalTime.MIDNIGHT));
|
||||
// 신청 시엔 시작만 입력받고, 종료(퇴실)는 당일 마감으로 둔다 (실제 입·퇴장은 체크인/아웃에서 관리).
|
||||
req.setVisitTo(date.atTime(LocalTime.of(23, 59, 59)));
|
||||
|
||||
req.setVisitorName(requireString(row.getCell(6), "이름"));
|
||||
req.setCompany(getString(row.getCell(7)));
|
||||
req.setContact(requireString(row.getCell(8), "연락처"));
|
||||
req.setVehicleNo(getString(row.getCell(9)));
|
||||
|
||||
// 출입통제담당자 (비면 서비스가 업로드 사용자로 채움)
|
||||
req.setControlName(getString(row.getCell(10)));
|
||||
req.setControlTeam(getString(row.getCell(11)));
|
||||
req.setControlContact(getString(row.getCell(12)));
|
||||
// 현장감시자1 (비면 서비스가 고정값으로 채움)
|
||||
req.setWatcher1Name(getString(row.getCell(13)));
|
||||
req.setWatcher1Team(getString(row.getCell(14)));
|
||||
req.setWatcher1Contact(getString(row.getCell(15)));
|
||||
// 현장감시자2
|
||||
req.setWatcher2Name(getString(row.getCell(16)));
|
||||
req.setWatcher2Team(getString(row.getCell(17)));
|
||||
req.setWatcher2Contact(getString(row.getCell(18)));
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Splits the 장소 cell into individual zones on comma / semicolon / slash (usually one). */
|
||||
private List<String> parseServerRooms(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> rooms = new ArrayList<>();
|
||||
for (String part : raw.split("[,;/]")) {
|
||||
String trimmed = part.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
rooms.add(trimmed);
|
||||
}
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
// A pre-numbered but otherwise blank template row (순번 filled, rest empty) is skipped;
|
||||
// 순번(0) is ignored so those rows don't count as data.
|
||||
private static final int[] KEY_COLS = {1, 3, 4, 6, 8};
|
||||
|
||||
private boolean isEmptyRow(Row row) {
|
||||
for (int c = 0; c <= 8; c++) {
|
||||
for (int c : KEY_COLS) {
|
||||
String v = getString(row.getCell(c));
|
||||
if (v != null && !v.isBlank()) {
|
||||
return false;
|
||||
@@ -118,21 +168,62 @@ public class ExcelImportService {
|
||||
return v;
|
||||
}
|
||||
|
||||
private LocalDateTime requireDateTime(Cell cell, String field) {
|
||||
/** Parses 출입일자: Excel date cell, or text like "2026.7.8(수)" / "2026-07-08" / "2026.07.08". */
|
||||
private LocalDate requireDate(Cell cell, String field) {
|
||||
if (cell == null) {
|
||||
throw new IllegalArgumentException(field + "은(는) 필수입니다.");
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
|
||||
return cell.getLocalDateTimeCellValue();
|
||||
return cell.getLocalDateTimeCellValue().toLocalDate();
|
||||
}
|
||||
String text = requireString(cell, field);
|
||||
String norm = text;
|
||||
int paren = norm.indexOf('(');
|
||||
if (paren >= 0) {
|
||||
norm = norm.substring(0, paren); // "(요일)" 제거
|
||||
}
|
||||
norm = norm.trim().replace('.', '-').replace('/', '-').replace(" ", "");
|
||||
norm = norm.replaceAll("-{2,}", "-").replaceAll("-+$", "");
|
||||
try {
|
||||
if (text.length() <= 10) {
|
||||
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
|
||||
}
|
||||
return LocalDateTime.parse(text.replace('T', ' '), DT);
|
||||
return LocalDate.parse(norm, DATE_FMT);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text);
|
||||
throw new IllegalArgumentException(
|
||||
field + " 형식이 올바르지 않습니다 (예: 2026-07-08 또는 2026.7.8): " + text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses 출입시간 (optional): Excel time cell, "HH:mm" text, or a day fraction (0.625 = 15:00). */
|
||||
private LocalTime parseTime(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
if (DateUtil.isCellDateFormatted(cell)) {
|
||||
return cell.getLocalDateTimeCellValue().toLocalTime();
|
||||
}
|
||||
return fractionToTime(cell.getNumericCellValue());
|
||||
}
|
||||
String t = getString(cell);
|
||||
if (t == null || t.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
t = t.trim();
|
||||
try {
|
||||
if (t.matches("\\d{1,2}:\\d{2}(:\\d{2})?")) {
|
||||
return LocalTime.parse(t, TIME_FMT);
|
||||
}
|
||||
double d = Double.parseDouble(t);
|
||||
return fractionToTime(d);
|
||||
} catch (Exception e) {
|
||||
return null; // 해석 불가한 시간 → 시작시간 미지정으로 처리
|
||||
}
|
||||
}
|
||||
|
||||
private LocalTime fractionToTime(double d) {
|
||||
double frac = d - Math.floor(d);
|
||||
if (frac < 0) {
|
||||
return null;
|
||||
}
|
||||
return LocalTime.ofSecondOfDay(Math.round(frac * 86400) % 86400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public class ReportService {
|
||||
List<VisitRequest> rows = visitRequestRepository
|
||||
.findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay());
|
||||
|
||||
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"};
|
||||
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "작업명", "출입일시", "퇴실일시", "상태"};
|
||||
|
||||
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = wb.createSheet("출입기록");
|
||||
@@ -77,9 +77,10 @@ public class ReportService {
|
||||
put(row, 3, nv(vr.getZoneName()), widths);
|
||||
put(row, 4, vr.getHost().getFullName(), widths);
|
||||
put(row, 5, nv(vr.getPurpose()), widths);
|
||||
put(row, 6, fmt(vr.getVisitFrom()), widths);
|
||||
put(row, 7, fmt(vr.getVisitTo()), widths);
|
||||
put(row, 8, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths);
|
||||
put(row, 6, nv(vr.getWorkName()), widths);
|
||||
put(row, 7, fmt(vr.getVisitFrom()), widths);
|
||||
put(row, 8, fmt(vr.getVisitTo()), widths);
|
||||
put(row, 9, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths);
|
||||
}
|
||||
|
||||
// autoSizeColumn under-measures CJK text, so set widths from the content
|
||||
|
||||
@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@@ -24,15 +25,23 @@ public class VisitRequestService {
|
||||
private final VisitorRepository visitorRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
// 현장감시자1은 고정 인원. (실제 지정 인원이 다르면 이 상수만 수정)
|
||||
private static final String FIXED_WATCHER1_NAME = "류관순";
|
||||
private static final String FIXED_WATCHER1_TEAM = "IT전략국";
|
||||
private static final String FIXED_WATCHER1_CONTACT = "313";
|
||||
|
||||
/**
|
||||
* Create a pre-registration. Reuses an existing visitor (matched by name+contact)
|
||||
* or creates a new one. The created request starts in PENDING.
|
||||
* Create pre-registration(s). Reuses an existing visitor (matched by name+contact)
|
||||
* or creates a new one, then issues one PENDING request per resolved access zone —
|
||||
* so selecting two server rooms produces two requests (and, on approval, two QR passes).
|
||||
*/
|
||||
public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) {
|
||||
public List<VisitRequest> createRequests(VisitRequestCreateRequest req, Long hostUserId) {
|
||||
if (req.getVisitTo().isBefore(req.getVisitFrom())) {
|
||||
throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다.");
|
||||
}
|
||||
|
||||
List<String> zoneNames = resolveZones(req);
|
||||
|
||||
User host = userRepository.findById(hostUserId)
|
||||
.orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다."));
|
||||
|
||||
@@ -46,20 +55,84 @@ public class VisitRequestService {
|
||||
visitor.setVehicleNo(req.getVehicleNo());
|
||||
visitor = visitorRepository.save(visitor);
|
||||
|
||||
if (visitRequestRepository.existsActiveDuplicate(
|
||||
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) {
|
||||
throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)");
|
||||
List<VisitRequest> created = new ArrayList<>();
|
||||
for (String zoneName : zoneNames) {
|
||||
if (visitRequestRepository.existsActiveDuplicate(
|
||||
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), zoneName)) {
|
||||
throw ApiException.conflict(
|
||||
"이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복: " + zoneName + ")");
|
||||
}
|
||||
VisitRequest vr = new VisitRequest();
|
||||
vr.setVisitor(visitor);
|
||||
vr.setHost(host);
|
||||
vr.setZoneName(zoneName);
|
||||
vr.setPurpose(req.getPurpose());
|
||||
vr.setWorkName(req.getWorkName());
|
||||
vr.setVisitFrom(req.getVisitFrom());
|
||||
vr.setVisitTo(req.getVisitTo());
|
||||
vr.setStatus(VisitStatus.PENDING);
|
||||
|
||||
// 출입통제담당자 = 요청값 있으면 사용(엑셀), 없으면 본인(등록한 로그인 사용자) 정보로 채움.
|
||||
vr.setControlName(orElse(req.getControlName(), host.getFullName()));
|
||||
vr.setControlTeam(orElse(req.getControlTeam(), host.getDepartment()));
|
||||
vr.setControlContact(orElse(req.getControlContact(), host.getEmail()));
|
||||
// 현장감시자1 = 고정값(요청값 비면 고정 상수).
|
||||
vr.setWatcher1Name(orElse(req.getWatcher1Name(), FIXED_WATCHER1_NAME));
|
||||
vr.setWatcher1Team(orElse(req.getWatcher1Team(), FIXED_WATCHER1_TEAM));
|
||||
vr.setWatcher1Contact(orElse(req.getWatcher1Contact(), FIXED_WATCHER1_CONTACT));
|
||||
// 현장감시자2 = 담당자 입력값 그대로.
|
||||
vr.setWatcher2Name(trimToNull(req.getWatcher2Name()));
|
||||
vr.setWatcher2Team(trimToNull(req.getWatcher2Team()));
|
||||
vr.setWatcher2Contact(trimToNull(req.getWatcher2Contact()));
|
||||
|
||||
created.add(visitRequestRepository.save(vr));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Trimmed value, or null when blank. */
|
||||
private static String trimToNull(String v) {
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
String t = v.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
/** The trimmed value if present, otherwise the fallback. */
|
||||
private static String orElse(String value, String fallback) {
|
||||
String v = trimToNull(value);
|
||||
return v != null ? v : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the access-zone labels for a request. Each selected server room (전산실) is a
|
||||
* zone of its own (its own QR); the detail room (콤보박스) is auxiliary and appended to
|
||||
* the label. With no server room selected, the detail room becomes the sole zone.
|
||||
*/
|
||||
private List<String> resolveZones(VisitRequestCreateRequest req) {
|
||||
String room = req.getRoomZone() == null ? "" : req.getRoomZone().trim();
|
||||
|
||||
List<String> serverRooms = new ArrayList<>();
|
||||
if (req.getServerRooms() != null) {
|
||||
for (String s : req.getServerRooms()) {
|
||||
if (s != null && !s.trim().isEmpty()) {
|
||||
serverRooms.add(s.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VisitRequest vr = new VisitRequest();
|
||||
vr.setVisitor(visitor);
|
||||
vr.setHost(host);
|
||||
vr.setZoneName(req.getZoneName());
|
||||
vr.setPurpose(req.getPurpose());
|
||||
vr.setVisitFrom(req.getVisitFrom());
|
||||
vr.setVisitTo(req.getVisitTo());
|
||||
vr.setStatus(VisitStatus.PENDING);
|
||||
return visitRequestRepository.save(vr);
|
||||
List<String> zones = new ArrayList<>();
|
||||
if (!serverRooms.isEmpty()) {
|
||||
for (String sr : serverRooms) {
|
||||
zones.add(room.isEmpty() ? sr : sr + " / " + room);
|
||||
}
|
||||
} else if (!room.isEmpty()) {
|
||||
zones.add(room);
|
||||
} else {
|
||||
throw ApiException.badRequest("출입 구역을 최소 1개 이상 선택하세요.");
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Visit request detail fields added after the initial schema.
|
||||
ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255);
|
||||
ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80);
|
||||
ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60);
|
||||
Reference in New Issue
Block a user