feat(user): 사용자 자기정보 편집과 관리자 재설정 플로우를 연동

- lib/widgets/app_shell.dart에서 내 정보 다이얼로그를 추가하고 UserRepository.updateMe·비밀번호 변경 로직을 연결

- lib/features/masters/user/* 모듈에 phone·forcePasswordChange·passwordUpdatedAt 필드를 반영하고 reset-password/update-me API를 사용

- lib/core/validation/password_rules.dart을 신설해 비밀번호 정책 검증을 공통화하고 신규 위젯·테스트에서 재사용

- doc/stock_approval_system_api_v4.md 등 문서를 users 스펙 개편 내용으로 갱신하고 user_management_plan.md를 추가

- test/widgets/app_shell_test.dart 등에서 자기정보 수정·비밀번호 재설정 시나리오를 검증하고 기존 테스트를 보강
This commit is contained in:
JiWoong Sul
2025-10-26 17:05:47 +09:00
parent 9beb161527
commit 14624c4165
23 changed files with 1958 additions and 194 deletions

View File

@@ -17,9 +17,15 @@
- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수).
- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외).
- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행.
- 대시보드 대기 결재 요약은 상세 조회 연계를 위해 각 항목의 `approval_id`(= `approvals.id`)를 포함한다.
- 결재 목록 응답은 각 항목의 `id`(= `approvals.id`)를 항상 노출하여 상세 조회 트리거로 사용한다.
- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가.
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
- 모든 직원은 **그룹**에 속하며(`employees.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
- 모든 로그인 사용자는 **users** 테이블 행과 매핑되며(`users.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
- `users.employee_id`는 영문/숫자 4~32자 정규식(`^[A-Za-z0-9]{4,32}$`)을 따라야 하며, 대소문자 구분 없이 유니크하다.
- 신규 사용자는 생성 직후 `force_password_change=true`로 저장하고, 최초 로그인에서 비밀번호를 변경하지 않으면 다른 기능을 사용할 수 없다.
- 비밀번호는 길이 8~24자에 대문자·소문자·숫자·일반 특수문자를 각각 1자 이상 포함해야 하며, 성공적으로 변경되면 기존 세션을 즉시 만료한다.
- 최고 관리자 계정 `terabits`는 삭제하지 않고 최상위 권한을 유지하며, 관리자 비밀번호 재설정은 임시 비밀번호 이메일 발송과 `force_password_change=true` 설정을 동시에 수행한다.
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
@@ -53,13 +59,13 @@ approval_actions ||--o{ approval_histories : acted_as
approval_templates ||--o{ approval_template_steps : has_sequence
employees ||--o{ approvals : requested_by
employees ||--o{ approval_steps : assigned_to
employees ||--o{ approval_histories : actor
employees ||--o{ stock_transactions : created_by
employees ||--o{ approval_templates : authored
employees ||--o{ approval_template_steps : template_approver
groups ||--o{ employees : members
users ||--o{ approvals : requested_by
users ||--o{ approval_steps : assigned_to
users ||--o{ approval_histories : actor
users ||--o{ stock_transactions : created_by
users ||--o{ approval_templates : authored
users ||--o{ approval_template_steps : template_approver
groups ||--o{ users : members
groups ||--o{ group_menu_permissions : controls
menus ||--o{ group_menu_permissions : target
zipcodes ||--o{ warehouses : located
@@ -123,6 +129,8 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
---
### 3.3 `customers` (고객사)
@@ -154,25 +162,33 @@ zipcodes ||--o{ customers : addressed
---
### 3.4 `employees` (사)
### 3.4 `users` (사용자)
| 영문테이블명 | 한글테이블명 |
|---|---|
| employees | 사 |
| users | 사용자 |
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 사ID | bigint | - | identity | Y | Y | Y | - |
| employee_no | 사번 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| employee_name | 성명 | varchar | 100 | - | Y | | | |
| email | 이메일 | varchar | 100 | - | N | Y | | |
| mobile_no | 모바일번호 | varchar | 20 | - | N | | | |
| id | 사용자ID | bigint | - | identity | Y | Y | Y | - |
| employee_id | 사번 | varchar | 32 | - | Y | (부분유니크: is_deleted=false) | N | - |
| name | 이름 | varchar | 100 | - | Y | | | |
| email | 이메일 | varchar | 150 | - | Y | Y | | |
| phone | 연락처 | varchar | 30 | - | N | | | |
| group_id | 그룹ID | bigint | - | - | Y | | | groups.id |
| password_hash | 비밀번호해시 | varchar | 255 | - | Y | | | - |
| password_updated_at | 비밀번호변경일시 | timestamp | - | now() | Y | | | - |
| force_password_change | 비밀번호강제변경 | boolean | - | false | Y | | | |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `employee_id`는 영문/숫자 4~32자(`^[A-Za-z0-9]{4,32}$`)로 제한하며 대소문자를 구분하지 않는 유니크 인덱스를 적용한다.
> 이메일은 소문자로 저장하고 유니크해야 하며, 연락처는 국제 전화번호 포맷을 허용한다.
> 신규 생성 시 `force_password_change=true`로 저장하고, 최초 로그인 후 비밀번호 변경 시 `force_password_change=false` 및 `password_updated_at`을 현재 시각으로 갱신한다.
> 최고 관리자 계정 `terabits`는 삭제하지 않고 유지하며, 비밀번호 재설정 시 이메일 발송과 즉시 비밀번호 강제 변경 상태로 전환한다.
---
### 3.5 `zipcodes` (우편번호)
@@ -260,7 +276,7 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사원은 `employees.group_id`로 그룹에 연결.
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사용자는 `users.group_id`로 그룹에 연결된다.
---
@@ -381,7 +397,7 @@ zipcodes ||--o{ customers : addressed
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
| transaction_date | 처리일자 | date | - | current_date | Y | | | - |
| created_by_id | 작성자ID | bigint | - | - | N | | | employees.id |
| created_by_id | 작성자ID | bigint | - | - | N | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -391,6 +407,7 @@ zipcodes ||--o{ customers : addressed
> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
> 작성자(`created_by_id`)는 로그인 세션의 사용자 ID를 사용하며, API 요청 본문에 전달된 다른 값은 무시하거나 검증 오류로 처리한다.
---
@@ -492,7 +509,7 @@ zipcodes ||--o{ customers : addressed
| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| approval_status_id | 전체결재상태ID | bigint | - | - | Y | | | approval_statuses.id |
| current_step_id | 현재단계ID | bigint | - | - | N | | | approval_steps.id |
| requested_by_id | 상신자ID | bigint | - | - | Y | | | employees.id |
| requested_by_id | 상신자ID | bigint | - | - | Y | | | users.id |
| requested_at | 상신일시 | timestamp | - | now() | Y | | | - |
| decided_at | 최종결정일시 | timestamp | - | - | N | | | - |
| note | 비고 | text | - | - | N | | | - |
@@ -515,7 +532,7 @@ zipcodes ||--o{ customers : addressed
| id | 단계ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| step_status_id | 단계상태ID | bigint | - | - | Y | | | approval_statuses.id |
| assigned_at | 배정일시 | timestamp | - | now() | Y | | | - |
| decided_at | 결정일시 | timestamp | - | - | N | | | - |
@@ -537,7 +554,7 @@ zipcodes ||--o{ customers : addressed
| id | 이력ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id |
| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id |
| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id |
@@ -561,7 +578,7 @@ zipcodes ||--o{ customers : addressed
| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| template_name | 템플릿명 | varchar | 100 | - | Y | | | |
| description | 설명 | varchar | 255 | - | N | | | - |
| created_by_id | 작성자ID | bigint | - | - | Y | | | employees.id |
| created_by_id | 작성자ID | bigint | - | - | Y | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -580,7 +597,7 @@ zipcodes ||--o{ customers : addressed
| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - |
| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -593,13 +610,13 @@ zipcodes ||--o{ customers : addressed
## 4) FK 관계 (source → target)
- `menus.parent_menu_id``menus.id`
- `employees.group_id``groups.id`
- `users.group_id``groups.id`
- `group_menu_permissions.group_id``groups.id`
- `group_menu_permissions.menu_id``menus.id`
- `products.vendor_id``vendors.id`
- `products.uom_id``uoms.id`
- `stock_transactions.warehouse_id``warehouses.id`
- `stock_transactions.created_by_id``employees.id`
- `stock_transactions.created_by_id``users.id`
- `stock_transactions.transaction_type_id``transaction_types.id`
- `stock_transactions.transaction_status_id``transaction_statuses.id`
- `transaction_lines.transaction_id``stock_transactions.id`
@@ -609,19 +626,19 @@ zipcodes ||--o{ customers : addressed
- `approvals.transaction_id``stock_transactions.id`
- `approvals.approval_status_id``approval_statuses.id`
- `approvals.current_step_id``approval_steps.id`
- `approvals.requested_by_id``employees.id`
- `approvals.requested_by_id``users.id`
- `approval_steps.approval_id``approvals.id`
- `approval_steps.approver_id``employees.id`
- `approval_steps.approver_id``users.id`
- `approval_steps.step_status_id``approval_statuses.id`
- `approval_histories.approval_id``approvals.id`
- `approval_histories.approval_step_id``approval_steps.id`
- `approval_histories.approver_id``employees.id`
- `approval_histories.approver_id``users.id`
- `approval_histories.approval_action_id``approval_actions.id`
- `approval_histories.from_status_id``approval_statuses.id`
- `approval_histories.to_status_id``approval_statuses.id`
- `approval_templates.created_by_id``employees.id`
- `approval_templates.created_by_id``users.id`
- `approval_template_steps.template_id``approval_templates.id`
- `approval_template_steps.approver_id``employees.id`
- `approval_template_steps.approver_id``users.id`
---
@@ -633,13 +650,17 @@ zipcodes ||--o{ customers : addressed
- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가.
- 수량/단가 음수 금지(CHECK).
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
-의 소속 그룹(`employees.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
-용자의 소속 그룹(`users.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
- `users.employee_id`는 앞뒤 공백을 제거한 뒤 대소문자 구분 없이 중복 검증하며, 저장 시 대문자로 정규화한다.
- 자기 정보 수정(`PATCH /users/me`)에서는 `phone`, `email`, `password`만 변경할 수 있고, `password` 변경 시 기존 비밀번호 검증이 필수다.
- 비밀번호 재설정(관리자)은 8자 영문 대소문자+숫자 조합을 생성하고 이메일 발송 큐에 푸시한 뒤 `force_password_change=true`, `password_updated_at=now()`로 기록한다.
- 비밀번호 변경이 성공하면 기존 세션은 즉시 만료하고, 강제 로그아웃 알림을 반환한다. 이후 무효화된 토큰으로 요청하면 `token revoked` 메시지를 반환해야 한다.
---
## 6) 인덱스/유니크 권장
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `users(employee_id)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
- `group_menu_permissions(group_id, menu_id, is_deleted)`
- `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장
- `transaction_lines(transaction_id, line_no, is_deleted)`
@@ -663,9 +684,10 @@ zipcodes ||--o{ customers : addressed
4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성.
5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속).
6) `approval_statuses``is_blocking_next`, `is_terminal` 값 시드.
7) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사원-그룹 매핑 `employees.group_id`이관.
8) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
9) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
7) `users` 테이블에 `employee_id`, `name`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 컬럼을 추가하고 기존 `employee_no`, `employee_name`, `mobile_no` 데이터를 규칙에 맞게 마이그레이션한다. 그룹 매핑 `users.group_id`유지한다.
8) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사용자-그룹 매핑을 `users.group_id`로 유지한다.
9) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
10) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
---