# 供應商管理 - 供應商基本資料新增功能規格

**系統名稱：** 小東 POS 2.0  
**模組：** 採購管理（Purchasing）  
**功能：** 供應商新增（Supplier Create）  
**路由：** `/purchasing/suppliers/new`  
**技術棧：** Angular + Java (Spring Boot)  
**文件版本：** v1.0  
**最後更新：** 2026-04-25

---

## 1. 功能概述

供應商新增頁面提供使用者建立新供應商基本資料的入口，包含兩個分頁：

1. **基本資料** — 填寫供應商基本欄位（本規格範疇）
2. **相關採購訂單** — 顯示該供應商的採購訂單（新增時為空，本規格不包含）

供應商編號由系統**自動產生**，使用者無法手動輸入。填寫完成後點擊「儲存」呼叫 Insert API 建立資料，成功後返回列表頁。

---

## 2. 頁面佈局

```
┌──────────────────────────────────────────────────────────────┐
│  ≡  供應商管理                                   👤 andy ▾   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  供應商新增   [回上一頁（藍底白字）]                          │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  [基本資料（底線藍色，active）]  [相關採購訂單]      │    │
│  │ ────────────────────────────────────────────────    │    │
│  │                                                     │    │
│  │  供應商編號 *  [系統自動產生，唯讀]                  │    │
│  │  供應商名稱 *  [________________]                   │    │
│  │  聯絡人姓名    [________________]                   │    │
│  │  統一編號      [________________]                   │    │
│  │                                                     │    │
│  │  公司地址      [________________]                   │    │
│  │  服務類別      [請選擇          ▾]                  │    │
│  │  負責業務   *  [請選擇    👤]                       │    │
│  │  付款條件      [請選擇          ▾]                  │    │
│  │                                                     │    │
│  │  聯絡電話      [________________]                   │    │
│  │  備註說明      [________________]                   │    │
│  │  風險評估      [請選擇          ▾]                  │    │
│  │  供應商狀態 *  [請選擇          ▾]                  │    │
│  │                                                     │    │
│  │                    [  儲存（橘色）  ]               │    │
│  └─────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘
```

---

## 3. 頁面標題區

### 3.1 標題

- 文字：`供應商新增`
- 樣式：藍色粗體（`color: #1976D2; font-weight: bold`）

### 3.2 回上一頁按鈕

| 屬性 | 值 |
|------|-----|
| 文字 | 回上一頁 |
| 樣式 | 藍底白字，圓角（`background: #1976D2; color: #FFF; border-radius: 20px`） |
| 行為 | 導頁至 `/purchasing/suppliers`，**不儲存**任何資料 |

---

## 4. Tab 分頁規格

| Tab 名稱 | 路由片段 | 說明 |
|----------|----------|------|
| 基本資料 | （預設，active） | 本規格範疇 |
| 相關採購訂單 | — | 新增時 Tab 存在但內容為空，不可操作 |

- Active Tab 樣式：文字藍色、底部藍色底線
- Inactive Tab 樣式：文字灰色、無底線

---

## 5. 基本資料表單欄位定義

### 5.1 欄位清單

表單採 **4 欄 Grid 佈局**，每列放 4 個欄位（標籤 + 輸入元件）。

| 列 | 欄位標籤 | 欄位 ID | 元件類型 | 必填 | 說明 |
|----|----------|---------|----------|------|------|
| Row 1 | 供應商編號 | `supplierCode` | text input（唯讀） | ✅ | 系統自動產生，頁面載入時由 API 取得，使用者不可編輯 |
| Row 1 | 供應商名稱 | `supplierName` | text input | ✅ | 最多 100 字元 |
| Row 1 | 聯絡人姓名 | `contactName` | text input | ❌ | 最多 50 字元 |
| Row 1 | 統一編號 | `taxId` | text input | ❌ | 8 碼數字，格式驗證 |
| Row 2 | 公司地址 | `address` | text input | ❌ | 最多 200 字元 |
| Row 2 | 服務類別 | `serviceCategory` | select dropdown | ❌ | 選項從 API 動態載入，見 §5.2 |
| Row 2 | 負責業務 | `ownerUserId` | select dropdown + 👤 icon | ✅ | 從員工資料表 API 載入，見 §5.3 |
| Row 2 | 付款條件 | `paymentTerm` | select dropdown | ❌ | 選項見 §5.4 |
| Row 3 | 聯絡電話 | `phone` | text input | ❌ | 格式：數字與 `-`，最多 20 字元 |
| Row 3 | 備註說明 | `remark` | text input | ❌ | 最多 500 字元 |
| Row 3 | 風險評估 | `riskLevel` | select dropdown | ❌ | 選項見 §5.5 |
| Row 3 | 供應商狀態 | `supplierStatus` | select dropdown | ✅ | 選項見 §5.6 |

> `*` 標記代表必填欄位，標籤後方顯示紅色星號（`color: #F44336`）

### 5.2 服務類別選項（`serviceCategory`）

從後端 API 動態載入：`GET /api/reference/service-categories`

```
請選擇（預設，值為空）
文具用品   → STATIONERY
生產製造   → MANUFACTURING
包裝       → PACKAGING
物流運輸   → LOGISTICS
其他       → OTHER
```

### 5.3 負責業務選項（`ownerUserId`）— 員工 API

從員工資料表 API 動態載入：`GET /api/hr/employees/active`

```
請選擇（預設，值為空）
{員工姓名} → {員工代碼}  （依 API 回傳動態渲染）
```

- 下拉選單顯示**員工姓名**，實際儲存值為**員工代碼（ID）**
- 僅載入**在職（active）**員工
- 右側 👤 icon 點擊：保留 icon 樣式，行為同下拉選單展開（或未來擴充為人員選取 Popup）

### 5.4 付款條件選項（`paymentTerm`）

```
請選擇（預設，值為空）
30天付款   → DAYS_30
35天付款   → DAYS_35
60天付款   → DAYS_60
月結        → MONTHLY
貨到付款   → COD
```

### 5.5 風險評估選項（`riskLevel`）

```
請選擇（預設，值為空）
低   → LOW
中   → MEDIUM
高   → HIGH
```

### 5.6 供應商狀態選項（`supplierStatus`）— 必填

```
請選擇（預設，值為空）
合作中      → ACTIVE
暫停合作    → SUSPENDED
終止合作    → TERMINATED
```

---

## 6. 供應商編號自動產生規則

| 屬性 | 說明 |
|------|------|
| 產生時機 | 頁面載入時，前端呼叫 `GET /api/purchasing/suppliers/next-code` 取得 |
| 格式 | `PO` + `YYMMDD`（建單日期） + 6位流水號，例：`PO2604250001` |
| 欄位狀態 | `readonly`，顯示灰底樣式（`background: #F5F5F5`）以示不可編輯 |
| 流水號規則 | 每日從 000001 重置，由後端維護 |

---

## 7. 儲存按鈕

| 屬性 | 值 |
|------|-----|
| 文字 | 儲存 |
| 位置 | 表單底部置中 |
| 樣式 | 橘色（`background: #FF9800; color: #FFF; border-radius: 20px; padding: 10px 40px`） |
| 行為 | 觸發表單驗證 → 通過後呼叫 Insert API → 成功後導頁至列表頁並顯示成功訊息 |
| Loading 狀態 | 點擊後按鈕 disabled + 顯示 Spinner，防止重複送出 |

---

## 8. API 規格

### 8.1 取得下一個供應商編號

```
GET /api/purchasing/suppliers/next-code
```

**Response 200：**

```json
{
  "supplierCode": "PO2604250001"
}
```

### 8.2 取得在職員工列表（負責業務下拉選單）

```
GET /api/hr/employees/active
```

**Response 200：**

```json
[
  {
    "employeeId": "EMP001",
    "employeeName": "王小明",
    "department": "採購部"
  },
  {
    "employeeId": "EMP002",
    "employeeName": "李大華",
    "department": "業務部"
  }
]
```

### 8.3 新增供應商（Insert API）

```
POST /api/purchasing/suppliers
```

**Request Body：**

```json
{
  "supplierCode": "PO2604250001",
  "supplierName": "小東實業有限公司",
  "contactName": "王小明",
  "taxId": "12345678",
  "address": "台北市中正區XX路1號",
  "serviceCategory": "STATIONERY",
  "ownerUserId": "EMP001",
  "paymentTerm": "DAYS_30",
  "phone": "02-2345-6789",
  "remark": "長期合作供應商",
  "riskLevel": "LOW",
  "supplierStatus": "ACTIVE"
}
```

**Request 欄位說明：**

| 欄位名稱 | 型別 | 必填 | 說明 |
|----------|------|------|------|
| `supplierCode` | string | ✅ | 由前端帶入自動產生的編號 |
| `supplierName` | string | ✅ | 最多 100 字元 |
| `contactName` | string | ❌ | 最多 50 字元 |
| `taxId` | string | ❌ | 8 碼數字 |
| `address` | string | ❌ | 最多 200 字元 |
| `serviceCategory` | string | ❌ | 服務類別代碼 |
| `ownerUserId` | string | ✅ | 員工代碼 |
| `paymentTerm` | string | ❌ | 付款條件代碼 |
| `phone` | string | ❌ | 聯絡電話 |
| `remark` | string | ❌ | 備註說明，最多 500 字元 |
| `riskLevel` | string | ❌ | `LOW` / `MEDIUM` / `HIGH` |
| `supplierStatus` | string | ✅ | `ACTIVE` / `SUSPENDED` / `TERMINATED` |

**Response 201（建立成功）：**

```json
{
  "success": true,
  "message": "供應商新增成功",
  "supplierId": "uuid-string",
  "supplierCode": "PO2604250001"
}
```

**Response 400（驗證失敗）：**

```json
{
  "success": false,
  "message": "欄位驗證失敗",
  "errors": [
    { "field": "supplierName", "message": "供應商名稱為必填" },
    { "field": "taxId", "message": "統一編號格式不正確" }
  ]
}
```

**Response 409（供應商編號重複）：**

```json
{
  "success": false,
  "message": "供應商編號已存在，請重新整理頁面"
}
```

### 8.4 取得服務類別選項

```
GET /api/reference/service-categories
```

（同查詢規格 §5.3，頁面初始化時一併呼叫）

---

## 9. Angular 元件規格

### 9.1 元件結構

```
purchasing/
└── suppliers/
    ├── suppliers.module.ts
    ├── suppliers-routing.module.ts
    ├── supplier-create/
    │   ├── supplier-create.component.ts
    │   ├── supplier-create.component.html
    │   └── supplier-create.component.scss
    └── services/
        ├── supplier.service.ts
        └── employee.service.ts
```

### 9.2 SupplierCreateComponent

```typescript
// supplier-create.component.ts
@Component({
  selector: 'app-supplier-create',
  templateUrl: './supplier-create.component.html',
  styleUrls: ['./supplier-create.component.scss']
})
export class SupplierCreateComponent implements OnInit {
  createForm: FormGroup;
  isSubmitting = false;
  isLoadingCode = false;

  // 下拉選單資料來源
  serviceCategories: ReferenceOption[] = [];
  employees: EmployeeOption[] = [];
  paymentTerms: ReferenceOption[] = [];
  riskLevels: ReferenceOption[] = [];
  supplierStatuses: ReferenceOption[] = [];

  ngOnInit(): void {
    this.initForm();
    this.loadNextCode();       // 取得自動編號
    this.loadServiceCategories();
    this.loadEmployees();
  }

  initForm(): void { ... }          // 初始化 ReactiveForm
  loadNextCode(): void { ... }      // 呼叫 GET /api/purchasing/suppliers/next-code
  loadServiceCategories(): void { ... }
  loadEmployees(): void { ... }
  onSave(): void { ... }            // 驗證 → POST API → 導頁
  onBack(): void { ... }            // 導頁至 /purchasing/suppliers
}
```

### 9.3 表單初始化（ReactiveFormsModule）

```typescript
initForm(): void {
  this.createForm = this.fb.group({
    supplierCode:    [{ value: '', disabled: true }],   // 唯讀
    supplierName:    ['', [Validators.required, Validators.maxLength(100)]],
    contactName:     ['', [Validators.maxLength(50)]],
    taxId:           ['', [Validators.pattern(/^\d{8}$/)]],
    address:         ['', [Validators.maxLength(200)]],
    serviceCategory: [''],
    ownerUserId:     ['', Validators.required],
    paymentTerm:     [''],
    phone:           ['', [Validators.pattern(/^[\d\-]{0,20}$/)]],
    remark:          ['', [Validators.maxLength(500)]],
    riskLevel:       [''],
    supplierStatus:  ['', Validators.required],
  });
}
```

### 9.4 儲存流程（onSave）

```typescript
onSave(): void {
  if (this.createForm.invalid) {
    this.createForm.markAllAsTouched(); // 觸發所有欄位顯示錯誤訊息
    return;
  }

  this.isSubmitting = true;

  const payload: CreateSupplierRequest = {
    ...this.createForm.getRawValue(), // getRawValue 包含 disabled 的 supplierCode
  };

  this.supplierService.createSupplier(payload).subscribe({
    next: (res) => {
      this.toastService.success('供應商新增成功');
      this.router.navigate(['/purchasing/suppliers']);
    },
    error: (err) => {
      this.isSubmitting = false;
      if (err.status === 409) {
        this.toastService.error('供應商編號已存在，請重新整理頁面');
      } else {
        this.toastService.error('新增失敗，請稍後再試');
      }
    }
  });
}
```

### 9.5 SupplierService 新增方法

```typescript
// supplier.service.ts
@Injectable({ providedIn: 'root' })
export class SupplierService {
  // 既有方法...
  getSuppliers(params: SupplierQueryParams): Observable<SupplierListResponse> { ... }
  deleteSupplier(supplierId: string): Observable<ApiResponse> { ... }
  getServiceCategories(): Observable<ReferenceOption[]> { ... }

  // 新增方法
  getNextCode(): Observable<{ supplierCode: string }> {
    return this.http.get<{ supplierCode: string }>(`${this.baseUrl}/next-code`);
  }

  createSupplier(payload: CreateSupplierRequest): Observable<CreateSupplierResponse> {
    return this.http.post<CreateSupplierResponse>(this.baseUrl, payload);
  }
}
```

### 9.6 EmployeeService

```typescript
// employee.service.ts
@Injectable({ providedIn: 'root' })
export class EmployeeService {
  private baseUrl = '/api/hr/employees';

  getActiveEmployees(): Observable<EmployeeOption[]> {
    return this.http.get<EmployeeOption[]>(`${this.baseUrl}/active`);
  }
}
```

### 9.7 資料模型（TypeScript Interface）

```typescript
// 新增 Request
export interface CreateSupplierRequest {
  supplierCode: string;
  supplierName: string;
  contactName?: string;
  taxId?: string;
  address?: string;
  serviceCategory?: string;
  ownerUserId: string;
  paymentTerm?: string;
  phone?: string;
  remark?: string;
  riskLevel?: 'LOW' | 'MEDIUM' | 'HIGH';
  supplierStatus: 'ACTIVE' | 'SUSPENDED' | 'TERMINATED';
}

// 新增 Response
export interface CreateSupplierResponse {
  success: boolean;
  message: string;
  supplierId: string;
  supplierCode: string;
}

// 員工選項
export interface EmployeeOption {
  employeeId: string;
  employeeName: string;
  department: string;
}

// 通用下拉選項
export interface ReferenceOption {
  code: string;
  label: string;
}
```

---

## 10. 後端 Java 規格

### 10.1 Controller（新增端點）

```java
@RestController
@RequestMapping("/api/purchasing/suppliers")
public class SupplierController {

    // 既有端點...
    @GetMapping
    public ResponseEntity<PageResult<SupplierDTO>> getSuppliers(...) { ... }

    @DeleteMapping("/{supplierId}")
    public ResponseEntity<ApiResponse> deleteSupplier(...) { ... }

    // 新增端點
    @GetMapping("/next-code")
    public ResponseEntity<NextCodeResponse> getNextCode() { ... }

    @PostMapping
    public ResponseEntity<CreateSupplierResponse> createSupplier(
        @RequestBody @Valid CreateSupplierRequest request
    ) { ... }
}
```

### 10.2 Request DTO（含 Bean Validation）

```java
public class CreateSupplierRequest {

    @NotBlank(message = "供應商編號為必填")
    @Size(max = 30)
    private String supplierCode;

    @NotBlank(message = "供應商名稱為必填")
    @Size(max = 100)
    private String supplierName;

    @Size(max = 50)
    private String contactName;

    @Pattern(regexp = "^\\d{8}$", message = "統一編號須為 8 碼數字")
    private String taxId;

    @Size(max = 200)
    private String address;

    private String serviceCategory;

    @NotBlank(message = "負責業務為必填")
    private String ownerUserId;

    private String paymentTerm;

    @Pattern(regexp = "^[\\d\\-]{0,20}$", message = "聯絡電話格式不正確")
    private String phone;

    @Size(max = 500)
    private String remark;

    private String riskLevel;   // LOW / MEDIUM / HIGH

    @NotBlank(message = "供應商狀態為必填")
    private String supplierStatus;  // ACTIVE / SUSPENDED / TERMINATED
}
```

### 10.3 Response DTO

```java
public class CreateSupplierResponse {
    private boolean success;
    private String message;
    private String supplierId;
    private String supplierCode;
}

public class NextCodeResponse {
    private String supplierCode;
}
```

### 10.4 員工 API Controller

```java
@RestController
@RequestMapping("/api/hr/employees")
public class EmployeeController {

    @GetMapping("/active")
    public ResponseEntity<List<EmployeeOptionDTO>> getActiveEmployees() { ... }
}

public class EmployeeOptionDTO {
    private String employeeId;
    private String employeeName;
    private String department;
}
```

### 10.5 供應商編號產生邏輯（Service 層）

```java
// SupplierService.java
public String generateNextCode() {
    String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"));
    // 查詢當日最大流水號
    int seq = supplierRepository.findMaxSeqByDate(dateStr) + 1;
    return String.format("PO%s%06d", dateStr, seq);
}
```

---

## 11. 表單驗證規則

| 欄位 | 必填 | 格式規則 | 前端錯誤訊息 |
|------|------|----------|-------------|
| 供應商編號 | ✅ | 系統產生，唯讀 | — |
| 供應商名稱 | ✅ | 最多 100 字元 | `供應商名稱為必填` |
| 負責業務 | ✅ | 需選取員工 | `負責業務為必填` |
| 供應商狀態 | ✅ | 需選取選項 | `供應商狀態為必填` |
| 統一編號 | ❌ | 若填寫，限 8 碼純數字 | `統一編號須為 8 碼數字` |
| 聯絡電話 | ❌ | 若填寫，限數字與 `-`，最多 20 碼 | `聯絡電話格式不正確` |
| 供應商名稱 | ❌ | 最多 100 字元 | `供應商名稱不可超過 100 字元` |
| 備註說明 | ❌ | 最多 500 字元 | `備註說明不可超過 500 字元` |

**驗證時機：**
- 欄位 blur（離開焦點）時顯示單一欄位錯誤
- 點擊「儲存」時觸發全表單驗證（`markAllAsTouched()`）
- 錯誤訊息顯示於欄位下方（紅色字體 `#F44336`）

---

## 12. 使用者互動與錯誤處理

### 12.1 頁面初始化流程

```
頁面載入
  ├─ 呼叫 GET /api/purchasing/suppliers/next-code → 填入供應商編號（唯讀）
  ├─ 呼叫 GET /api/reference/service-categories  → 填充服務類別下拉選單
  ├─ 呼叫 GET /api/hr/employees/active           → 填充負責業務下拉選單
  └─ 其他下拉選項（付款條件、風險評估、狀態）使用前端靜態常數
```

### 12.2 Toast 通知

| 情境 | 提示類型 | 提示內容 |
|------|----------|----------|
| 新增成功 | Toast Success（綠） | `供應商新增成功` |
| 供應商編號重複 | Toast Error（紅） | `供應商編號已存在，請重新整理頁面` |
| 新增 API 失敗 | Toast Error（紅） | `新增失敗，請稍後再試` |
| 取得編號 API 失敗 | Toast Warning（橘） | `無法取得供應商編號，請重新整理頁面` |
| 取得員工清單失敗 | Toast Warning（橘） | `無法載入員工資料，請重新整理頁面` |

### 12.3 Loading 狀態

| 情境 | 行為 |
|------|------|
| 頁面初始化 API 載入中 | 各下拉選單顯示 `載入中...` 並 disabled |
| 儲存送出中 | 儲存按鈕顯示 Spinner + disabled，防止重複送出 |

### 12.4 離開頁面確認（可選）

> 若表單已有任何修改且尚未儲存，使用者點擊「回上一頁」或瀏覽器返回時，顯示確認 Dialog：

```
標題：確認離開
內容：目前有未儲存的資料，確定要離開嗎？
按鈕：[取消]  [確認離開]
```

---

## 13. 其他備註

- 供應商編號格式：`PO` + `YYMMDD` + 6位流水號（每日重置），由後端 `GET /next-code` 產生
- 「相關採購訂單」Tab 在新增模式下不可點擊（disabled），待儲存成功後進入編輯模式方可查看
- 負責業務的 👤 icon 目前行為為展開下拉選單；若未來需要支援人員搜尋 Popup，可另立「人員選取元件」共用規格
- 付款條件、風險評估選項為靜態常數，無需 API 動態載入
- 後端需實作 `@UniqueConstraint` 確保 `supplierCode` 唯一，前端碰到 409 時提示重新整理
