refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리

- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel,
  NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합
- input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수
- 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog +
  SequenceManagementPanel 신설
- backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트)
- input canonical 진행 노트 + 채번 관리 mockup 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:42:13 +09:00
parent 59f5cf22f0
commit a5bbd1eb7c
40 changed files with 3735 additions and 1247 deletions
@@ -11,7 +11,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/numbering-rule")
@RequestMapping("/api/numbering-rules")
@RequiredArgsConstructor
@Slf4j
public class NumberingRuleController {
@@ -136,7 +136,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
// ================================================================
@@ -202,7 +202,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
}
/** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */
@@ -215,7 +215,7 @@ public class NumberingRuleController {
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
String userInputCode = body != null ? (String) body.get("user_input_code") : null;
String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다."));
}
/** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */
@@ -224,18 +224,63 @@ public class NumberingRuleController {
@RequestAttribute("company_code") String companyCode,
@PathVariable String ruleId) {
String code = numberingRuleService.generateCode(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다."));
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다."));
}
/** POST /{ruleId}/reset → 순번 초기화 */
/** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */
private boolean isAdminRole(String role) {
return "SUPER_ADMIN".equals(role)
|| "ADMIN".equals(role)
|| "COMPANY_ADMIN".equals(role);
}
/** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */
@PostMapping("/{ruleId}/reset")
public ResponseEntity<ApiResponse<Void>> resetSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
numberingRuleService.resetSequence(ruleId, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다."));
}
/** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */
@PutMapping("/{ruleId}/sequence")
public ResponseEntity<ApiResponse<Void>> updateRuleSequence(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@PathVariable String ruleId,
@RequestBody Map<String, Object> body) {
if (!isAdminRole(role)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Object seqObj = body.get("sequence");
if (seqObj == null) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 값이 필요합니다."));
}
Integer newSequence;
try {
newSequence = (seqObj instanceof Number)
? ((Number) seqObj).intValue()
: Integer.parseInt(seqObj.toString());
} catch (NumberFormatException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 정수여야 합니다."));
}
if (newSequence < 0) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("sequence 는 0 이상이어야 합니다."));
}
numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다."));
}
// ================================================================
// ■ Admin
// ================================================================
@@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService {
return allocateCode(ruleId, companyCode, null, null);
}
/** POST /:ruleId/reset → 순번 초기화 */
/**
* POST /:ruleId/reset → 순번 초기화 (admin)
*
* 두 테이블 다 처리:
* 1. numbering_rule_sequences (prefix 별 발번 카운터, 실제 ground truth) 전체 DELETE → 다음 발번 1 부터
* 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set
* - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음)
*/
@Transactional
public void resetSequence(String ruleId, String companyCode) {
Map<String, Object> params = new HashMap<>();
@@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService {
params.put("company_code", companyCode);
params.put("current_sequence", 0);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "updateCurrentSequenceInRule", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode);
}
/**
* PUT /:ruleId/sequence → 현재 시퀀스 임의 값으로 수정 (admin)
*
* admin 이 "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터.
* 두 테이블 다 처리:
* 1. numbering_rule_sequences (prefix 별 실제 카운터) 전체 DELETE
* → 다음 allocate 시 새 row 가 INSERT (current_sequence=1) 되거나
* 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 수 있음
* - 운영 전 단계라 historical sequence 폐기 안전
* 2. numbering_rules.current_sequence 를 newSequence 로 set
*/
@Transactional
public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("rule_id", ruleId);
params.put("company_code", companyCode);
params.put("current_sequence", newSequence);
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
sqlSession.update(NS + "setCurrentSequenceInRule", params);
log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode);
}
// ================================================================
// ■ Available Rules
// ================================================================
@@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService {
return seq == null ? 0L : ((Number) seq).longValue();
}
/** 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING */
/**
* 순번 증가 UPSERT ON CONFLICT DO UPDATE RETURNING.
*
* INSERT 분기의 base 값:
* - 동일 prefix 의 row 가 없을 때 (첫 발번 / admin reset 후 / 새 카테고리 등)
* `numbering_rules.current_sequence + 1` 부터 시작.
* - 의미: admin 이 sequence 를 N 으로 set 하고 historical sequences 를 비웠을 때,
* 다음 발번이 N+1 부터 정확히 시작되도록.
* - numbering_rules row 가 없는 비정상 케이스는 0+1=1.
*/
private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) {
String sql = """
INSERT INTO numbering_rule_sequences
(rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES (?, ?, ?, 1, NOW())
VALUES (
?, ?, ?,
COALESCE((
SELECT current_sequence
FROM numbering_rules
WHERE rule_id = ?
AND (company_code = ? OR company_code = '*')
LIMIT 1
), 0) + 1,
NOW()
)
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET
current_sequence = numbering_rule_sequences.current_sequence + 1,
@@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService {
RETURNING current_sequence
""";
Long newSeq = jdbcTemplate.queryForObject(sql, Long.class,
ruleId, companyCode, prefixKey);
ruleId, companyCode, prefixKey, ruleId, companyCode);
return newSeq != null ? newSeq : 1L;
}