# T525 — opportunity-model-repository

> Status: `done`  
> Milestone: `M2`  
> Kind: `feat`  
> Est. change: `~190 lines`

## Goal

完成後系統應：Opportunity domain 與 repository 就位，狀態機依 spec §3.2，同 owner 同 `external_id` 去重且可記錄多個觸發 term。

## Depends on

- T501

## Inputs

- Spec：`../spec.md` §3.2、§3.3、§8、§9.2（OP-06、SW-06）
- 既有 module 模式：`apps/backend/internal/module/scout/**`

## Outputs

### 程式變更（預期路徑）

| 路徑 | 動作 | 說明 |
|------|------|------|
| `apps/backend/internal/module/radar/domain/opportunity.go` | add | struct＋status 轉移＋`intent_band` 由 score 推導（80／50）＋`reasons[]` 五維度驗證 |
| `apps/backend/internal/module/radar/repository/opportunity_mongo.go` | add | `UpsertByExternalID`、`ListByOwner`（band／status／watch／日期 filter）、`CountToday`、`Get`、`UpdateStatus`、`SetOverride` |
| `apps/backend/internal/module/radar/repository/opportunity_memory.go` | add | 測試用 |

### 行為變更

- `reasons[]` 不足五維度時 repository **拒絕寫入**（回明確錯誤）——OP-06。
- `UpsertByExternalID`：命中既有則只把新的觸發 term 併入 `matched_terms[]`，不重複建立、不重跑判定。
- `region_match` 為三態 `match|mismatch|unknown`；`status` 含 `judging|qualified|rejected|accepted|dismissed`。
- `override` 記 `{from_band, to_band, from_status, to_status, actor_uid, at}`。

## Out of scope

- 判定演算法（T529）與寫入流程（T530）
- HTTP logic（T545／T546／T547）

## Acceptance

- [x] unit test：缺一條 reason 被拒；同 `external_id` 兩次 upsert 只有一筆且 `matched_terms` 有兩個
- [x] score 79→`mid`、80→`high`、49→`low`
- [x] 指令：

```bash
cd apps/backend && go test ./internal/module/radar/... -count=1
```

## Notes

band 門檻 80／50 為 spec 鎖定值，**不可**在此 task 調整。

### 實作落點

- `domain/opportunity.go`：status 轉移、`BandFromScore`（80／50）、`ValidateReasons` 五維度、`UTCDayBounds`
- `repository/opportunity_{memory,mongo}.go`：`UpsertByExternalID` 命中只併 `matched_terms`；`CountToday`／`ListOpportunities`／`SetOpportunityOverride`
- 測試：`domain/opportunity_test.go`、`repository/opportunity_memory_test.go`
