fix all bug

This commit is contained in:
王性驊 2026-08-19 02:01:15 +00:00
parent 7defbed562
commit 61a2d332dd
49 changed files with 6787 additions and 6129 deletions

View File

@ -494,6 +494,10 @@ func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Servi
if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil { if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil {
return fmt.Errorf("invalid scout scan payload: %w", err) return fmt.Errorf("invalid scout scan payload: %w", err)
} }
if payload.RunID == "" {
// 部署前排入的舊 job 只存平面 RunBriefrun id 一律等於 Job.RefID
payload.RunID = j.RefID
}
if payload.RunID == "" || payload.RunID != j.RefID { if payload.RunID == "" || payload.RunID != j.RefID {
return fmt.Errorf("scout run/job reference mismatch") return fmt.Errorf("scout run/job reference mismatch")
} }
@ -530,8 +534,9 @@ func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Servi
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error()) _ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error())
return err return err
} }
// 已發佈即成功;進度回報失敗只記 log不可把 job 標為失敗
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已發佈 %d 筆候選", len(posts))); err != nil { if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已發佈 %d 筆候選", len(posts))); err != nil {
return err logx.Errorf("scout scan %s progress after publish: %v", j.ID, err)
} }
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts))) _, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))
return err return err

View File

@ -66,7 +66,8 @@ function textMatchesQuery(text: string, query: string): boolean {
if (anchors.length > 0) { if (anchors.length > 0) {
return anchors.some((a) => body.includes(a)); return anchors.some((a) => body.includes(a));
} }
return true; // 查詢過短無法斷詞時,退回要求正文含原始查詢字串,避免放行整條推薦流
return body.includes(q.replace(/\s+/g, "").toLowerCase());
} }
function parsePublishedFromCardText(text: string): { iso?: string; label?: string } { function parsePublishedFromCardText(text: string): { iso?: string; label?: string } {
@ -243,31 +244,35 @@ async function search(storageState: string, terms: string[], limit: number): Pro
const perTrack = Math.min(Math.max(limit, 10), 24); const perTrack = Math.min(Math.max(limit, 10), 24);
const pageTop = await context.newPage(); const pageTop = await context.newPage();
const pageRecent = await context.newPage(); const pageRecent = await context.newPage();
let top: Post[] = []; // allSettled 保留成功軌結果失敗軌各自重試一次session 失效重試無意義)
let recent: Post[] = []; const isSessionError = (r: unknown) => r instanceof Error && r.message.includes("session");
try { const [topResult, recentResult] = await Promise.allSettled([
[top, recent] = await Promise.all([
searchTrack(pageTop, query, "top", perTrack), searchTrack(pageTop, query, "top", perTrack),
searchTrack(pageRecent, query, "recent", perTrack), searchTrack(pageRecent, query, "recent", perTrack),
]); ]);
} catch (e) { let top: Post[] = topResult.status === "fulfilled" ? topResult.value : [];
// 一軌失敗仍用另一軌 let recent: Post[] = recentResult.status === "fulfilled" ? recentResult.value : [];
if (top.length === 0) { const firstFailure =
topResult.status === "rejected"
? topResult.reason
: recentResult.status === "rejected"
? recentResult.reason
: undefined;
if (topResult.status === "rejected" && !isSessionError(topResult.reason)) {
try { try {
top = await searchTrack(pageTop, query, "top", perTrack); top = await searchTrack(pageTop, query, "top", perTrack);
} catch { } catch {
/* keep empty */ /* keep empty */
} }
} }
if (recent.length === 0 && !(e instanceof Error && e.message.includes("session"))) { if (recentResult.status === "rejected" && !isSessionError(recentResult.reason)) {
try { try {
recent = await searchTrack(pageRecent, query, "recent", perTrack); recent = await searchTrack(pageRecent, query, "recent", perTrack);
} catch { } catch {
/* keep empty */ /* keep empty */
} }
} }
if (top.length === 0 && recent.length === 0) throw e; if (firstFailure !== undefined && top.length === 0 && recent.length === 0) throw firstFailure;
}
await pageTop.close().catch(() => undefined); await pageTop.close().catch(() => undefined);
await pageRecent.close().catch(() => undefined); await pageRecent.close().catch(() => undefined);
const merged = mergeRecentPrimary(top, recent, query, limit); const merged = mergeRecentPrimary(top, recent, query, limit);

View File

@ -223,6 +223,9 @@ type (
Terms []TermConversionStat `json:"terms"` Terms []TermConversionStat `json:"terms"`
Variants []VariantConversionStat `json:"variants"` Variants []VariantConversionStat `json:"variants"`
Sources []SourceConversionStat `json:"sources"` Sources []SourceConversionStat `json:"sources"`
// UnavailableDimensions 列出「尚未實作」而非「查無資料」的維度,
// 前端才不會把功能缺口誤顯示為使用者還沒有數據。
UnavailableDimensions []string `json:"unavailable_dimensions"`
} }
) )

View File

@ -0,0 +1,3 @@
[
{ "dropIndexes": "jobs", "index": "radar_sweep_job_ref_unique" }
]

View File

@ -0,0 +1,13 @@
[
{
"createIndexes": "jobs",
"indexes": [
{
"key": { "owner_uid": 1, "template_type": 1, "ref_id": 1 },
"name": "radar_sweep_job_ref_unique",
"unique": true,
"partialFilterExpression": { "template_type": "radar_sweep" }
}
]
}
]

View File

@ -3,6 +3,7 @@ package crm
import ( import (
"context" "context"
crmUC "apps/backend/internal/module/crm/usecase"
"apps/backend/internal/svc" "apps/backend/internal/svc"
"apps/backend/internal/types" "apps/backend/internal/types"
@ -28,15 +29,28 @@ func (l *GetCrmStatsLogic) GetCrmStats(req *types.CrmStatsReq) (*types.CrmStatsD
if err != nil { if err != nil {
return nil, err return nil, err
} }
won := 0 terms := make([]types.TermConversionStat, 0, len(stats.Terms))
if v, ok := stats["won"].(int); ok { for _, s := range stats.Terms {
won = v row := types.TermConversionStat{
Term: s.Key, Accepted: s.Accepted, Replied: s.Replied, Won: s.Won,
InsufficientSample: s.Accepted < crmUC.StatsMinSample,
}
// 樣本不足只給絕對數不給會被過度解讀的比率spec §9.5
if !row.InsufficientSample {
row.ConversionRate = float64(s.Won) / float64(s.Accepted)
}
terms = append(terms, row)
}
sources := make([]types.SourceConversionStat, 0, len(stats.Sources))
for _, s := range stats.Sources {
sources = append(sources, types.SourceConversionStat{
Source: s.Key, Won: s.Won, InsufficientSample: s.Won < crmUC.StatsMinSample,
})
} }
return &types.CrmStatsData{ return &types.CrmStatsData{
Terms: []types.TermConversionStat{}, Terms: terms,
Variants: []types.VariantConversionStat{}, Variants: []types.VariantConversionStat{},
Sources: []types.SourceConversionStat{ Sources: sources,
{Source: "radar", Won: won, InsufficientSample: won < 5}, UnavailableDimensions: stats.UnavailableDimensions,
},
}, nil }, nil
} }

View File

@ -33,17 +33,24 @@ func (l *ListFollowUpsLogic) ListFollowUps(req *types.ListFollowUpsReq) (*types.
return nil, err return nil, err
} }
out := make([]types.FollowUpPublic, 0, len(list)) out := make([]types.FollowUpPublic, 0, len(list))
// 同一聯絡人常有多筆追蹤;快取避免整頁重複查同一筆
briefs := make(map[string]types.ContactBrief, len(list))
for _, f := range list { for _, f := range list {
p := crmmap.FollowUp(f) p := crmmap.FollowUp(f)
if p == nil { if p == nil {
continue continue
} }
brief, cached := briefs[f.ContactID]
if !cached {
if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil { if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil {
p.Contact = types.ContactBrief{ brief = types.ContactBrief{
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle, Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
DisplayName: c.DisplayName, Stage: c.Stage, DisplayName: c.DisplayName, Stage: c.Stage,
} }
} }
briefs[f.ContactID] = brief
}
p.Contact = brief
out = append(out, *p) out = append(out, *p)
} }
return &types.FollowUpListData{List: out, Pagination: crmmap.Pagination(req.Page, req.PageSize, total)}, nil return &types.FollowUpListData{List: out, Pagination: crmmap.Pagination(req.Page, req.PageSize, total)}, nil

View File

@ -65,16 +65,6 @@ func FollowUp(f *domain.FollowUp) *types.FollowUpPublic {
} }
} }
func FollowUpList(list []*domain.FollowUp) []types.FollowUpPublic {
out := make([]types.FollowUpPublic, 0, len(list))
for _, f := range list {
if p := FollowUp(f); p != nil {
out = append(out, *p)
}
}
return out
}
func StageCounts(m map[string]int) []types.StageCount { func StageCounts(m map[string]int) []types.StageCount {
out := make([]types.StageCount, 0, len(m)) out := make([]types.StageCount, 0, len(m))
for k, v := range m { for k, v := range m {

View File

@ -99,16 +99,6 @@ func Health(h *domain.AccountHealth) *types.AccountHealthPublic {
} }
} }
func HealthList(list []*domain.AccountHealth) []types.AccountHealthPublic {
out := make([]types.AccountHealthPublic, 0, len(list))
for _, h := range list {
if p := Health(h); p != nil {
out = append(out, *p)
}
}
return out
}
func Workspace(w *domain.Workspace) *types.WorkspacePublic { func Workspace(w *domain.Workspace) *types.WorkspacePublic {
if w == nil { if w == nil {
return nil return nil

View File

@ -34,6 +34,8 @@ const (
FollowUpEscalated = "escalated" FollowUpEscalated = "escalated"
DefaultFollowUpDays = 3 DefaultFollowUpDays = 3
// MaxFollowUpNotifications通知達此次數仍無動作即 escalatedspec FU-03
MaxFollowUpNotifications = 2
) )
type Contact struct { type Contact struct {

View File

@ -304,7 +304,14 @@ func (m *Memory) ListDueFollowUps(_ context.Context, now int64, limit int) ([]*d
defer m.mu.Unlock() defer m.mu.Unlock()
out := make([]*domain.FollowUp, 0) out := make([]*domain.FollowUp, 0)
for _, x := range m.followups { for _, x := range m.followups {
if (x.Status == domain.FollowUpScheduled || x.Status == domain.FollowUpSnoozed) && x.DueAt <= now { // notified 也要再掃:第二次通知(進而 escalated靠的是它下一次到期。
// snoozed 已不再寫入,保留以相容既有資料。
switch x.Status {
case domain.FollowUpScheduled, domain.FollowUpSnoozed, domain.FollowUpNotified:
default:
continue
}
if x.DueAt <= now {
cp := *x cp := *x
out = append(out, &cp) out = append(out, &cp)
} }

View File

@ -281,8 +281,12 @@ func (s *MonStore) ListDueFollowUps(ctx context.Context, now int64, limit int) (
limit = 50 limit = 50
} }
var list []*domain.FollowUp var list []*domain.FollowUp
// notified 也要再掃:第二次通知(進而 escalated靠的是它下一次到期。
// snoozed 已不再寫入,保留以相容既有資料。
err := s.followups.Find(ctx, &list, bson.M{ err := s.followups.Find(ctx, &list, bson.M{
"status": bson.M{"$in": []string{domain.FollowUpScheduled, domain.FollowUpSnoozed}}, "status": bson.M{"$in": []string{
domain.FollowUpScheduled, domain.FollowUpSnoozed, domain.FollowUpNotified,
}},
"due_at": bson.M{"$lte": now}, "due_at": bson.M{"$lte": now},
}, options.Find().SetLimit(int64(limit))) }, options.Find().SetLimit(int64(limit)))
return list, err return list, err

View File

@ -0,0 +1,114 @@
package usecase
import (
"context"
"testing"
"time"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/module/crm/repository"
)
const day = int64(24 * time.Hour)
func seedFollowUp(t *testing.T, repo domain.Repository, days int) (*domain.Contact, *domain.FollowUp) {
t.Helper()
ctx := context.Background()
c, err := repo.UpsertContactByIdentity(ctx, &domain.Contact{
OwnerUID: 7, AuthorHandle: "buyer", FollowUpDays: days,
})
if err != nil {
t.Fatal(err)
}
f := &domain.FollowUp{
ID: domain.NewID(), OwnerUID: 7, ContactID: c.ID,
DueAt: 1, Status: domain.FollowUpScheduled,
CreatedAt: 1, UpdatedAt: 1,
}
if err := repo.SaveFollowUp(ctx, f); err != nil {
t.Fatal(err)
}
return c, f
}
// FU-01FU-03第一次通知後要再等一個間隔才第二次通知第二次之後才 escalated。
func TestScanFollowUpsNotifiesTwiceThenEscalates(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
_, f := seedFollowUp(t, repo, 3)
now := int64(10 * day)
if n, err := svc.ScanFollowUps(ctx, now); err != nil || n != 1 {
t.Fatalf("first scan n=%d err=%v", n, err)
}
first, err := repo.GetFollowUp(ctx, f.ID)
if err != nil || first.Status != domain.FollowUpNotified || first.NotifiedCount != 1 {
t.Fatalf("after first scan=%+v err=%v", first, err)
}
if first.DueAt != now+3*day {
t.Fatalf("first notify must push due_at by the contact interval, got %d", first.DueAt)
}
// 還沒到下一次到期:不可重複通知
if n, err := svc.ScanFollowUps(ctx, now+day); err != nil || n != 0 {
t.Fatalf("premature rescan n=%d err=%v", n, err)
}
if n, err := svc.ScanFollowUps(ctx, now+3*day); err != nil || n != 1 {
t.Fatalf("second scan n=%d err=%v", n, err)
}
second, err := repo.GetFollowUp(ctx, f.ID)
if err != nil || second.Status != domain.FollowUpEscalated || second.NotifiedCount != 2 {
t.Fatalf("after second scan=%+v err=%v", second, err)
}
// escalated 是終點:不再被掃到
if n, err := svc.ScanFollowUps(ctx, now+30*day); err != nil || n != 0 {
t.Fatalf("escalated must stop scanning n=%d err=%v", n, err)
}
}
// FU-04延後把到期日後移並回到 scheduled。
func TestSnoozeReturnsToScheduled(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
_, f := seedFollowUp(t, repo, 3)
if _, err := svc.ScanFollowUps(ctx, 10*day); err != nil {
t.Fatal(err)
}
before := domain.NowNano()
got, err := svc.SnoozeFollowUp(ctx, 7, f.ID, 5)
if err != nil {
t.Fatal(err)
}
if got.Status != domain.FollowUpScheduled {
t.Fatalf("snooze status = %s, want scheduled", got.Status)
}
if got.DueAt < before+5*day {
t.Fatalf("snooze must push due_at at least 5 days out, got %d", got.DueAt)
}
}
func TestScanFollowUpsFallsBackToDefaultInterval(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
f := &domain.FollowUp{
ID: domain.NewID(), OwnerUID: 7, ContactID: "missing-contact",
DueAt: 1, Status: domain.FollowUpScheduled, CreatedAt: 1, UpdatedAt: 1,
}
if err := repo.SaveFollowUp(ctx, f); err != nil {
t.Fatal(err)
}
now := int64(10 * day)
if _, err := svc.ScanFollowUps(ctx, now); err != nil {
t.Fatal(err)
}
got, err := repo.GetFollowUp(ctx, f.ID)
if err != nil || got.DueAt != now+int64(domain.DefaultFollowUpDays)*day {
t.Fatalf("missing contact should use default interval, got %+v err=%v", got, err)
}
}

View File

@ -20,9 +20,10 @@ type GrowthOutcomes interface {
type Service struct { type Service struct {
Repo domain.Repository Repo domain.Repository
Growth GrowthOutcomes Growth GrowthOutcomes
// RadarOpps optional for contact detail briefs // RadarOpps optional for contact detail briefs and conversion attribution
RadarOpps interface { RadarOpps interface {
GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error) GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error)
ListOpportunities(ctx context.Context, ownerUID int64, f radarDomain.OpportunityListFilter) ([]*radarDomain.Opportunity, int64, error)
} }
Notifier FollowUpNotifier Notifier FollowUpNotifier
} }
@ -382,7 +383,8 @@ func (s *Service) SnoozeFollowUp(ctx context.Context, ownerUID int64, id string,
if f.OwnerUID != ownerUID { if f.OwnerUID != ownerUID {
return nil, domain.ErrForbidden return nil, domain.ErrForbidden
} }
f.Status = domain.FollowUpSnoozed // spec FU-04延後只是把到期日後移狀態回到可再次排程的 scheduled。
f.Status = domain.FollowUpScheduled
f.DueAt = domain.NowNano() + int64(days)*int64(24*time.Hour) f.DueAt = domain.NowNano() + int64(days)*int64(24*time.Hour)
f.UpdatedAt = domain.NowNano() f.UpdatedAt = domain.NowNano()
if err := s.Repo.SaveFollowUp(ctx, f); err != nil { if err := s.Repo.SaveFollowUp(ctx, f); err != nil {
@ -428,11 +430,15 @@ func (s *Service) ScanFollowUps(ctx context.Context, now int64) (int, error) {
} }
n := 0 n := 0
for _, f := range due { for _, f := range due {
f.Status = domain.FollowUpNotified
f.NotifiedCount++ f.NotifiedCount++
f.UpdatedAt = now f.UpdatedAt = now
if f.NotifiedCount >= 2 { if f.NotifiedCount >= domain.MaxFollowUpNotifications {
// 達上限只建議轉未成交不再排下一次通知spec FU-03
f.Status = domain.FollowUpEscalated f.Status = domain.FollowUpEscalated
} else {
// 到期日必須往後推,否則下一個 tick 會立刻重複通知同一筆
f.Status = domain.FollowUpNotified
f.DueAt = now + int64(s.followUpDays(ctx, f.ContactID))*int64(24*time.Hour)
} }
if err := s.Repo.SaveFollowUp(ctx, f); err != nil { if err := s.Repo.SaveFollowUp(ctx, f); err != nil {
continue continue
@ -445,24 +451,13 @@ func (s *Service) ScanFollowUps(ctx context.Context, now int64) (int, error) {
return n, nil return n, nil
} }
// Stats returns three-dimension CRM stats for the range. // followUpDays resolves the owner's configured interval, falling back to the
func (s *Service) Stats(ctx context.Context, ownerUID int64, from, to int64) (map[string]any, error) { // default when the contact is gone or never had one set.
counts, err := s.Repo.CountByStage(ctx, ownerUID) func (s *Service) followUpDays(ctx context.Context, contactID string) int {
if err != nil { c, err := s.Repo.GetContact(ctx, contactID)
return nil, err if err != nil || c == nil || c.FollowUpDays <= 0 {
return domain.DefaultFollowUpDays
} }
list, total, err := s.Repo.ListContacts(ctx, ownerUID, domain.ContactListFilter{Page: 1, PageSize: 500}) return c.FollowUpDays
if err != nil {
return nil, err
}
won := counts[domain.StageWon]
_ = from
_ = to
return map[string]any{
"total_contacts": total,
"by_stage": counts,
"won": won,
"follow_up": counts["needs_follow_up"],
"sample": len(list),
}, nil
} }

View File

@ -0,0 +1,234 @@
package usecase
import (
"context"
"sort"
"apps/backend/internal/module/crm/domain"
radarDomain "apps/backend/internal/module/radar/domain"
)
// StatsMinSample 是能公布比率的最低樣本數spec §9.5:樣本 < 5 只給絕對數)。
const StatsMinSample = 5
// DimensionVariants 目前無法計算:回覆版本與成交之間還沒有歸因欄位,
// 硬回空陣列會讓前端把功能缺口顯示成「使用者還沒有資料」。
const DimensionVariants = "variants"
// DimensionTermsDimensionSources 需要 radar 商機才能歸因。
const (
DimensionTerms = "terms"
DimensionSources = "sources"
)
// ConversionStat 只帶絕對數;比率是否揭露由樣本門檻決定。
type ConversionStat struct {
Key string
Accepted int
Replied int
Won int
}
// StatsResult 是 CRM 轉換統計。UnavailableDimensions 明確標出「尚未實作/無法計算」
// 的維度,讓呼叫端能跟「查得到但沒有資料」區分開來。
type StatsResult struct {
TotalContacts int64
ByStage map[string]int
NeedsFollowUp int
Terms []ConversionStat
Sources []ConversionStat
UnavailableDimensions []string
}
/*
Stats 聚合 CRM 轉換統計fromto 任一邊為 0 代表該側不設限
兩種維度的時間語意不同因為問的問題不同
- 名單分佈看聯絡人最近活動時間落在區間內
- 關鍵字來源看商機的建立時間落在區間內成交與否則取該聯絡人的現況階段
*/
func (s *Service) Stats(ctx context.Context, ownerUID int64, from, to int64) (*StatsResult, error) {
out := &StatsResult{
ByStage: map[string]int{},
Terms: []ConversionStat{},
Sources: []ConversionStat{},
// 回覆版本維度缺資料模型支援,永遠標為不可用。
UnavailableDimensions: []string{DimensionVariants},
}
stageByContact, err := s.collectContactStages(ctx, ownerUID, from, to, out)
if err != nil {
return nil, err
}
out.ByStage["needs_follow_up"] = out.NeedsFollowUp
if s.RadarOpps == nil {
out.UnavailableDimensions = append(out.UnavailableDimensions, DimensionTerms, DimensionSources)
return out, nil
}
terms, sources, err := s.attributeOpportunities(ctx, ownerUID, from, to, stageByContact)
if err != nil {
return nil, err
}
out.Terms, out.Sources = sortedStats(terms), sortedStats(sources)
return out, nil
}
// collectContactStages 走訪全部聯絡人:區間內的計入名單分佈,
// 同時建立 contact → stage 對照供商機歸因使用(避免逐筆回查)。
func (s *Service) collectContactStages(
ctx context.Context, ownerUID, from, to int64, out *StatsResult,
) (map[string]string, error) {
const pageSize = 500
stageByContact := map[string]string{}
scanned := 0
for page := 1; ; page++ {
list, total, err := s.Repo.ListContacts(ctx, ownerUID, domain.ContactListFilter{
Page: page, PageSize: pageSize,
})
if err != nil {
return nil, err
}
scanned += len(list)
for _, c := range list {
stageByContact[c.ID] = c.Stage
if !withinRange(contactActivityAt(c), from, to) {
continue
}
out.ByStage[c.Stage]++
if c.NeedsFollowUp {
out.NeedsFollowUp++
}
out.TotalContacts++
}
if len(list) == 0 || int64(scanned) >= total {
break
}
}
return stageByContact, nil
}
// attributeOpportunities 把已接受的商機分攤到關鍵字與來源兩個維度。
// 同一個聯絡人在同一關鍵字下只算一次,否則多筆商機會灌大 accepted。
func (s *Service) attributeOpportunities(
ctx context.Context, ownerUID, from, to int64, stageByContact map[string]string,
) (map[string]*ConversionStat, map[string]*ConversionStat, error) {
const pageSize = 200
terms := map[string]*ConversionStat{}
sources := map[string]*ConversionStat{}
countedTerm := map[string]bool{}
countedSource := map[string]bool{}
scanned := 0
for page := 1; ; page++ {
list, total, err := s.RadarOpps.ListOpportunities(ctx, ownerUID, radarDomain.OpportunityListFilter{
Statuses: []string{radarDomain.OppAccepted},
CreatedFrom: from,
CreatedTo: to,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, nil, err
}
scanned += len(list)
for _, o := range list {
if o == nil || o.ContactID == "" {
continue
}
stage, ok := stageByContact[o.ContactID]
if !ok {
continue
}
for _, term := range o.MatchedTerms {
if term == "" || countedTerm[o.ContactID+"\x00"+term] {
continue
}
countedTerm[o.ContactID+"\x00"+term] = true
tally(terms, term, stage)
}
source := conversionSource(o.Source)
if !countedSource[o.ContactID+"\x00"+source] {
countedSource[o.ContactID+"\x00"+source] = true
tally(sources, source, stage)
}
}
if len(list) == 0 || int64(scanned) >= total {
break
}
}
return terms, sources, nil
}
func tally(into map[string]*ConversionStat, key, stage string) {
stat := into[key]
if stat == nil {
stat = &ConversionStat{Key: key}
into[key] = stat
}
stat.Accepted++
if stageReachedReply(stage) {
stat.Replied++
}
if stage == domain.StageWon {
stat.Won++
}
}
// stageReachedReply現況階段已走到「對方回覆」之後才算 replied。
func stageReachedReply(stage string) bool {
switch stage {
case domain.StageReplied, domain.StageQuoted, domain.StageWon:
return true
}
return false
}
// conversionSource 把商機來源翻成統計維度的來源名稱。
func conversionSource(oppSource string) string {
switch oppSource {
case radarDomain.OppSourceScoutPromote:
return "scout"
case radarDomain.OppSourceManualImport:
return "manual_import"
default:
return "radar"
}
}
func contactActivityAt(c *domain.Contact) int64 {
if c.LastTouchAt > 0 {
return c.LastTouchAt
}
if c.UpdatedAt > 0 {
return c.UpdatedAt
}
return c.CreatedAt
}
func withinRange(at, from, to int64) bool {
if from > 0 && at < from {
return false
}
if to > 0 && at > to {
return false
}
return true
}
// sortedStats 以成交數→接受數→名稱排序,讓輸出穩定且高價值的排前面。
func sortedStats(in map[string]*ConversionStat) []ConversionStat {
out := make([]ConversionStat, 0, len(in))
for _, stat := range in {
out = append(out, *stat)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Won != out[j].Won {
return out[i].Won > out[j].Won
}
if out[i].Accepted != out[j].Accepted {
return out[i].Accepted > out[j].Accepted
}
return out[i].Key < out[j].Key
})
return out
}

View File

@ -0,0 +1,169 @@
package usecase
import (
"context"
"testing"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/module/crm/repository"
radarDomain "apps/backend/internal/module/radar/domain"
)
type stubRadarOpps struct{ opps []*radarDomain.Opportunity }
func (s stubRadarOpps) GetOpportunity(_ context.Context, id string) (*radarDomain.Opportunity, error) {
for _, o := range s.opps {
if o.ID == id {
return o, nil
}
}
return nil, radarDomain.ErrNotFound
}
func (s stubRadarOpps) ListOpportunities(
_ context.Context, ownerUID int64, f radarDomain.OpportunityListFilter,
) ([]*radarDomain.Opportunity, int64, error) {
matched := make([]*radarDomain.Opportunity, 0, len(s.opps))
for _, o := range s.opps {
if o.OwnerUID != ownerUID || o.Status != radarDomain.OppAccepted {
continue
}
if f.CreatedFrom > 0 && o.CreatedAt < f.CreatedFrom {
continue
}
if f.CreatedTo > 0 && o.CreatedAt > f.CreatedTo {
continue
}
matched = append(matched, o)
}
return matched, int64(len(matched)), nil
}
func seedStatsContact(t *testing.T, repo domain.Repository, handle, stage string, at int64) *domain.Contact {
t.Helper()
c, err := repo.UpsertContactByIdentity(context.Background(), &domain.Contact{
OwnerUID: 7, AuthorHandle: handle, Stage: stage, LastTouchAt: at,
})
if err != nil {
t.Fatal(err)
}
c.Stage = stage
c.LastTouchAt = at
if err := repo.SaveContact(context.Background(), c); err != nil {
t.Fatal(err)
}
return c
}
func acceptedOpp(id, contactID, source string, createdAt int64, terms ...string) *radarDomain.Opportunity {
return &radarDomain.Opportunity{
ID: id, OwnerUID: 7, Status: radarDomain.OppAccepted, Source: source,
ContactID: contactID, MatchedTerms: terms, CreatedAt: createdAt,
}
}
func TestStatsAttributesTermsAndSources(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
won := seedStatsContact(t, repo, "won-buyer", domain.StageWon, 100)
replied := seedStatsContact(t, repo, "replied-buyer", domain.StageReplied, 100)
cold := seedStatsContact(t, repo, "cold-buyer", domain.StageNewFound, 100)
svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{
acceptedOpp("o1", won.ID, radarDomain.OppSourceThreads, 100, "婚攝"),
// 同一聯絡人同一關鍵字的第二筆商機不可重複計入 accepted
acceptedOpp("o2", won.ID, radarDomain.OppSourceThreads, 101, "婚攝"),
acceptedOpp("o3", replied.ID, radarDomain.OppSourceThreads, 100, "婚攝"),
acceptedOpp("o4", cold.ID, radarDomain.OppSourceScoutPromote, 100, "外包"),
}}
got, err := svc.Stats(ctx, 7, 0, 0)
if err != nil {
t.Fatal(err)
}
terms := map[string]ConversionStat{}
for _, s := range got.Terms {
terms[s.Key] = s
}
if s := terms["婚攝"]; s.Accepted != 2 || s.Replied != 2 || s.Won != 1 {
t.Fatalf("婚攝 = %+v, want accepted 2 / replied 2 / won 1", s)
}
if s := terms["外包"]; s.Accepted != 1 || s.Replied != 0 || s.Won != 0 {
t.Fatalf("外包 = %+v", s)
}
sources := map[string]ConversionStat{}
for _, s := range got.Sources {
sources[s.Key] = s
}
if s := sources["radar"]; s.Accepted != 2 || s.Won != 1 {
t.Fatalf("radar source = %+v", s)
}
if s := sources["scout"]; s.Accepted != 1 {
t.Fatalf("scout source = %+v", s)
}
}
// 回覆版本維度沒有資料模型支援,必須明說不可用而不是回空清單。
func TestStatsFlagsVariantsUnavailable(t *testing.T) {
svc := New(repository.NewMemory())
svc.RadarOpps = stubRadarOpps{}
got, err := svc.Stats(context.Background(), 7, 0, 0)
if err != nil {
t.Fatal(err)
}
if !hasDimension(got.UnavailableDimensions, DimensionVariants) {
t.Fatalf("variants must be reported unavailable, got %v", got.UnavailableDimensions)
}
if hasDimension(got.UnavailableDimensions, DimensionTerms) {
t.Fatalf("terms is computable when radar is wired, got %v", got.UnavailableDimensions)
}
}
func TestStatsFlagsTermsUnavailableWithoutRadar(t *testing.T) {
svc := New(repository.NewMemory())
got, err := svc.Stats(context.Background(), 7, 0, 0)
if err != nil {
t.Fatal(err)
}
for _, dim := range []string{DimensionVariants, DimensionTerms, DimensionSources} {
if !hasDimension(got.UnavailableDimensions, dim) {
t.Fatalf("%s must be unavailable without radar, got %v", dim, got.UnavailableDimensions)
}
}
}
func TestStatsHonoursDateRange(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
old := seedStatsContact(t, repo, "old-buyer", domain.StageWon, 50)
recent := seedStatsContact(t, repo, "recent-buyer", domain.StageWon, 500)
svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{
acceptedOpp("o1", old.ID, radarDomain.OppSourceThreads, 50, "舊詞"),
acceptedOpp("o2", recent.ID, radarDomain.OppSourceThreads, 500, "新詞"),
}}
got, err := svc.Stats(ctx, 7, 400, 600)
if err != nil {
t.Fatal(err)
}
if got.TotalContacts != 1 || got.ByStage[domain.StageWon] != 1 {
t.Fatalf("range should keep only the recent contact, got %+v", got)
}
if len(got.Terms) != 1 || got.Terms[0].Key != "新詞" {
t.Fatalf("range should keep only the recent term, got %+v", got.Terms)
}
}
func hasDimension(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@ -4,6 +4,11 @@ import "context"
type Repository interface { type Repository interface {
Insert(ctx context.Context, j *Job) error Insert(ctx context.Context, j *Job) error
// InsertUniqueRef atomically inserts j unless a job with the same
// owner+template+ref already exists, in which case the existing job is
// returned with created=false. Check-then-insert is not enough: concurrent
// schedulers would each see "no existing job" and all insert.
InsertUniqueRef(ctx context.Context, j *Job) (stored *Job, created bool, err error)
Update(ctx context.Context, j *Job) error Update(ctx context.Context, j *Job) error
// UpdateOwned atomically requires the current running lease to belong to leaseOwner. // UpdateOwned atomically requires the current running lease to belong to leaseOwner.
UpdateOwned(ctx context.Context, j *Job, leaseOwner string) error UpdateOwned(ctx context.Context, j *Job, leaseOwner string) error

View File

@ -24,6 +24,23 @@ func (s *MemoryStore) Insert(_ context.Context, j *domain.Job) error {
return nil return nil
} }
func (s *MemoryStore) InsertUniqueRef(_ context.Context, j *domain.Job) (*domain.Job, bool, error) {
if j == nil {
return nil, false, domain.ErrNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
for _, stored := range s.byID {
if stored.OwnerUID == j.OwnerUID && stored.TemplateType == j.TemplateType && stored.RefID == j.RefID {
cp := *stored
return &cp, false, nil
}
}
cp := *j
s.byID[j.ID] = &cp
return j, true, nil
}
func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error { func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
return s.update(j, "") return s.update(j, "")
} }

View File

@ -38,6 +38,31 @@ func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error {
return err return err
} }
// InsertUniqueRef upserts on (owner_uid, template_type, ref_id) so concurrent
// schedulers converge on one job. The unique index from migration 000019 is
// what makes this safe; without it two upserts can both insert.
func (s *MonStore) InsertUniqueRef(ctx context.Context, j *domain.Job) (*domain.Job, bool, error) {
if j == nil {
return nil, false, domain.ErrNotFound
}
filter := bson.M{"owner_uid": j.OwnerUID, "template_type": j.TemplateType, "ref_id": j.RefID}
var stored domain.Job
err := s.claimJobs.FindOneAndUpdate(ctx, filter,
bson.M{"$setOnInsert": j},
options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
).Decode(&stored)
if err != nil {
// Lost the upsert race against the unique index: the winner is durable.
if mongo.IsDuplicateKeyError(err) {
if ferr := s.claimJobs.FindOne(ctx, filter).Decode(&stored); ferr == nil {
return &stored, false, nil
}
}
return nil, false, err
}
return &stored, stored.ID == j.ID, nil
}
func (s *MonStore) Update(ctx context.Context, j *domain.Job) error { func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
return s.update(ctx, j, nil) return s.update(ctx, j, nil)
} }

View File

@ -178,14 +178,6 @@ func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchI
day := time.Unix(0, runAt).UTC().Format("2006-01-02") day := time.Unix(0, runAt).UTC().Format("2006-01-02")
ref := RadarSweepRef(watchID, day) ref := RadarSweepRef(watchID, day)
existing, err := s.findRadarSweepForRef(ctx, ownerUID, ref)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day}) body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
if err != nil { if err != nil {
return nil, err return nil, err
@ -204,28 +196,15 @@ func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchI
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := s.Repo.Insert(ctx, j); err != nil { stored, created, err := s.Repo.InsertUniqueRef(ctx, j)
// Race with another scheduler: re-check and return the winner.
if again, ferr := s.findRadarSweepForRef(ctx, ownerUID, ref); ferr == nil && again != nil {
return again, nil
}
return nil, err
}
s.notify(ctx, j)
return j, nil
}
func (s *Service) findRadarSweepForRef(ctx context.Context, ownerUID int64, ref string) (*domain.Job, error) {
list, err := s.Repo.ListByOwner(ctx, ownerUID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, j := range list { // 只有真的建立才通知,否則同一天的重複排程會轟炸使用者。
if j.TemplateType == domain.TemplateRadarSweep && j.RefID == ref { if created {
return j, nil s.notify(ctx, stored)
} }
} return stored, nil
return nil, nil
} }
// ScheduleManualRadarSweep starts an extra patrol now. // ScheduleManualRadarSweep starts an extra patrol now.

View File

@ -102,7 +102,7 @@ func (m *ProductMatch) ValidateForWrite() error {
if m.Excluded && strings.TrimSpace(m.ExcludeReason) == "" { if m.Excluded && strings.TrimSpace(m.ExcludeReason) == "" {
return fmt.Errorf("%w: excluded product match requires exclude_reason", ErrValidation) return fmt.Errorf("%w: excluded product match requires exclude_reason", ErrValidation)
} }
m.Eligible = total >= 45 && painOrScenario && !m.Excluded m.Eligible = ProductFitEligible(total, painOrScenario, m.Excluded)
m.WatchIDs = uniqueStrings(m.WatchIDs) m.WatchIDs = uniqueStrings(m.WatchIDs)
m.MatchedTerms = NormalizeMatchedTerms(m.MatchedTerms) m.MatchedTerms = NormalizeMatchedTerms(m.MatchedTerms)
if m.Risks == nil { if m.Risks == nil {

View File

@ -32,6 +32,8 @@ func ProductFitDimensionWeight(dimension string) int {
} }
} }
func ProductFitEligible(score, pain, scenario int, excluded bool) bool { // ProductFitEligible is the single definition of "worth surfacing": enough
return score >= ProductFitEligibleMinScore && (pain > 0 || scenario > 0) && !excluded // total score, at least one pain or scenario hit, and not excluded.
func ProductFitEligible(score int, painOrScenario, excluded bool) bool {
return score >= ProductFitEligibleMinScore && painOrScenario && !excluded
} }

View File

@ -1,6 +1,9 @@
package domain package domain
import "strings" import (
"sort"
"strings"
)
// regionAliases maps common Taiwan place names (zh) → service area code. // regionAliases maps common Taiwan place names (zh) → service area code.
// Exact alias match only — never geo-infer (OP-04). // Exact alias match only — never geo-infer (OP-04).
@ -29,34 +32,46 @@ var regionAliases = map[string]string{
"連江": "LIE", "連江縣": "LIE", "馬祖": "LIE", "lie": "LIE", "連江": "LIE", "連江縣": "LIE", "馬祖": "LIE", "lie": "LIE",
} }
type regionAliasEntry struct {
runes []rune
code string
}
// sortedRegionAliases is built once at startup: longest alias first so 「台北市」
// wins over 「台北」, with the alias itself breaking ties so detection order is
// reproducible instead of following map iteration.
var sortedRegionAliases = buildSortedRegionAliases()
func buildSortedRegionAliases() []regionAliasEntry {
out := make([]regionAliasEntry, 0, len(regionAliases))
for alias, code := range regionAliases {
if alias == "" {
continue
}
out = append(out, regionAliasEntry{runes: []rune(strings.ToLower(alias)), code: code})
}
sort.Slice(out, func(i, j int) bool {
if len(out[i].runes) != len(out[j].runes) {
return len(out[i].runes) > len(out[j].runes)
}
return string(out[i].runes) < string(out[j].runes)
})
return out
}
// DetectRegionCodes finds service-area codes mentioned in free text (exact alias only). // DetectRegionCodes finds service-area codes mentioned in free text (exact alias only).
func DetectRegionCodes(text string) []string { func DetectRegionCodes(text string) []string {
lower := strings.ToLower(text) lower := strings.ToLower(text)
runes := []rune(lower)
if len(runes) == 0 {
return nil
}
seen := map[string]bool{} seen := map[string]bool{}
var out []string var out []string
// Longer aliases first so 「台北市」 wins over 「台北」. covered := make([]bool, len(runes))
type pair struct{ alias, code string } for _, p := range sortedRegionAliases {
pairs := make([]pair, 0, len(regionAliases)) ar := p.runes
for a, c := range regionAliases { if len(ar) > len(runes) {
pairs = append(pairs, pair{a, c})
}
// crude length sort
for i := 0; i < len(pairs); i++ {
for j := i + 1; j < len(pairs); j++ {
if len([]rune(pairs[j].alias)) > len([]rune(pairs[i].alias)) {
pairs[i], pairs[j] = pairs[j], pairs[i]
}
}
}
matched := make([]string, len(lower))
copy(matched, []string{}) // silence unused if empty
_ = matched
covered := make([]bool, len([]rune(lower)))
runes := []rune(lower)
for _, p := range pairs {
alias := strings.ToLower(p.alias)
ar := []rune(alias)
if len(ar) == 0 {
continue continue
} }
for i := 0; i+len(ar) <= len(runes); i++ { for i := 0; i+len(ar) <= len(runes); i++ {
@ -79,14 +94,16 @@ func DetectRegionCodes(text string) []string {
} }
} }
} }
// also direct code tokens // also direct code tokensmap 迭代無序,排序後再併入以維持輸出穩定
direct := make([]string, 0)
for code := range serviceAreaCodes { for code := range serviceAreaCodes {
if strings.Contains(lower, strings.ToLower(code)) && !seen[code] { if strings.Contains(lower, strings.ToLower(code)) && !seen[code] {
seen[code] = true seen[code] = true
out = append(out, code) direct = append(direct, code)
} }
} }
return out sort.Strings(direct)
return append(out, direct...)
} }
// MatchRegion compares detected codes against the owner's service areas. // MatchRegion compares detected codes against the owner's service areas.

View File

@ -54,5 +54,8 @@ type Repository interface {
// ReplyVariant // ReplyVariant
SaveReply(ctx context.Context, r *ReplyVariant) error SaveReply(ctx context.Context, r *ReplyVariant) error
ListReplies(ctx context.Context, ownerUID int64, opportunityID string) ([]*ReplyVariant, error) ListReplies(ctx context.Context, ownerUID int64, opportunityID string) ([]*ReplyVariant, error)
// ListRepliesForOpportunities fetches replies for many opportunities at once,
// keyed by opportunity id, so list pages do not issue one query per row.
ListRepliesForOpportunities(ctx context.Context, ownerUID int64, opportunityIDs []string) (map[string][]*ReplyVariant, error)
GetReply(ctx context.Context, id string) (*ReplyVariant, error) GetReply(ctx context.Context, id string) (*ReplyVariant, error)
} }

View File

@ -32,6 +32,30 @@ func (m *Memory) ListReplies(_ context.Context, ownerUID int64, opportunityID st
return out, nil return out, nil
} }
func (m *Memory) ListRepliesForOpportunities(_ context.Context, ownerUID int64, opportunityIDs []string) (map[string][]*domain.ReplyVariant, error) {
out := make(map[string][]*domain.ReplyVariant, len(opportunityIDs))
if len(opportunityIDs) == 0 {
return out, nil
}
wanted := make(map[string]bool, len(opportunityIDs))
for _, id := range opportunityIDs {
wanted[id] = true
}
m.mu.Lock()
defer m.mu.Unlock()
for _, r := range m.replies {
if r.OwnerUID != ownerUID || !wanted[r.OpportunityID] {
continue
}
cp := *r
out[r.OpportunityID] = append(out[r.OpportunityID], &cp)
}
for _, group := range out {
sort.Slice(group, func(i, j int) bool { return group[i].CreatedAt > group[j].CreatedAt })
}
return out, nil
}
func (m *Memory) GetReply(_ context.Context, id string) (*domain.ReplyVariant, error) { func (m *Memory) GetReply(_ context.Context, id string) (*domain.ReplyVariant, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -24,6 +24,25 @@ func (s *MonStore) ListReplies(ctx context.Context, ownerUID int64, opportunityI
return list, err return list, err
} }
func (s *MonStore) ListRepliesForOpportunities(ctx context.Context, ownerUID int64, opportunityIDs []string) (map[string][]*domain.ReplyVariant, error) {
out := make(map[string][]*domain.ReplyVariant, len(opportunityIDs))
if len(opportunityIDs) == 0 {
return out, nil
}
var list []*domain.ReplyVariant
err := s.replies.Find(ctx, &list, bson.M{
"owner_uid": ownerUID,
"opportunity_id": bson.M{"$in": opportunityIDs},
}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return nil, err
}
for _, r := range list {
out[r.OpportunityID] = append(out[r.OpportunityID], r)
}
return out, nil
}
func (s *MonStore) GetReply(ctx context.Context, id string) (*domain.ReplyVariant, error) { func (s *MonStore) GetReply(ctx context.Context, id string) (*domain.ReplyVariant, error) {
var r domain.ReplyVariant var r domain.ReplyVariant
err := s.replies.FindOne(ctx, &r, bson.M{"_id": id}) err := s.replies.FindOne(ctx, &r, bson.M{"_id": id})

View File

@ -1,13 +1,6 @@
package usecase package usecase
import ( import "context"
"context"
"fmt"
appnotifDomain "apps/backend/internal/module/appnotif/domain"
"github.com/google/uuid"
)
// AppNotifWriter is satisfied by appnotif usecase for system notifications. // AppNotifWriter is satisfied by appnotif usecase for system notifications.
type AppNotifWriter interface { type AppNotifWriter interface {
@ -15,27 +8,6 @@ type AppNotifWriter interface {
InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error
} }
// AppNotifBridge adapts appnotif.Service-like insert.
type AppNotifBridge struct {
Insert func(ctx context.Context, n *appnotifDomain.Notification) error
}
func (b *AppNotifBridge) InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error {
if b == nil || b.Insert == nil {
return nil
}
return b.Insert(ctx, &appnotifDomain.Notification{
ID: uuid.NewString(),
OwnerUID: ownerUID,
Title: title,
Body: body,
Kind: appnotifDomain.KindSystem,
RefType: refType,
RefID: refID,
CreatedAt: appnotifDomain.NowNano(),
})
}
// NotifierFromAppNotif builds SweepNotifier from appnotif bridge. // NotifierFromAppNotif builds SweepNotifier from appnotif bridge.
func NotifierFromAppNotif(w AppNotifWriter) SweepNotifier { func NotifierFromAppNotif(w AppNotifWriter) SweepNotifier {
return sweepNotifyFunc(func(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error { return sweepNotifyFunc(func(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error {
@ -56,6 +28,3 @@ type sweepNotifyFunc func(ctx context.Context, ownerUID int64, sweepID, watchID,
func (f sweepNotifyFunc) NotifySweepFailed(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error { func (f sweepNotifyFunc) NotifySweepFailed(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error {
return f(ctx, ownerUID, sweepID, watchID, reason) return f(ctx, ownerUID, sweepID, watchID, reason)
} }
// Ensure compile-time string for watchID usage in future deep-links.
var _ = fmt.Sprintf

View File

@ -83,7 +83,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
} }
productFiltered := productFilter.BrandID != "" || productFilter.ProductID != "" || productFilter.FitBand != "" productFiltered := productFilter.BrandID != "" || productFilter.ProductID != "" || productFilter.FitBand != ""
high, mid, low := []TodayOpportunity{}, []TodayOpportunity{}, []TodayOpportunity{} visible := make([]*domain.Opportunity, 0, len(list))
for _, o := range list { for _, o := range list {
if len(o.ProductMatches) > 0 && !todayHasEligibleProduct(o, productFilter) { if len(o.ProductMatches) > 0 && !todayHasEligibleProduct(o, productFilter) {
continue continue
@ -91,15 +91,27 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
if productFiltered && len(o.ProductMatches) == 0 { if productFiltered && len(o.ProductMatches) == 0 {
continue continue
} }
visible = append(visible, o)
}
visibleIDs := make([]string, 0, len(visible))
for _, o := range visible {
visibleIDs = append(visibleIDs, o.ID)
}
// 一次取回整頁的回覆;預設回覆只是輔助資訊,查不到不擋今日名單。
repliesByOpportunity, rerr := s.Repo.ListRepliesForOpportunities(ctx, ownerUID, visibleIDs)
if rerr != nil {
repliesByOpportunity = nil
}
high, mid, low := []TodayOpportunity{}, []TodayOpportunity{}, []TodayOpportunity{}
for _, o := range visible {
card := TodayOpportunity{Opportunity: o} card := TodayOpportunity{Opportunity: o}
if replies, rerr := s.Repo.ListReplies(ctx, ownerUID, o.ID); rerr == nil { for _, r := range repliesByOpportunity[o.ID] {
for _, r := range replies {
if r.Variant == domain.ReplyPublicComment { if r.Variant == domain.ReplyPublicComment {
card.DefaultReply = r card.DefaultReply = r
break break
} }
} }
}
switch o.IntentBand { switch o.IntentBand {
case domain.BandHigh: case domain.BandHigh:
high = append(high, card) high = append(high, card)

View File

@ -70,17 +70,13 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
} }
/* /*
assertCanActivate 變成 active的兩道閘SP-01RW-01 assertCanActivateForWatch 變成 active的兩道閘SP-01RW-01
exceptWatchID 是正在恢復的那一筆它目前不是 active所以不會被算進 CountActive exceptWatchID 是正在恢復的那一筆它目前不是 active所以不會被算進 CountActive
帶進來只為了在訊息與計算上表達清楚 帶進來只為了在訊息與計算上表達清楚
既有超額者不強制降級spec §3.1這裡只擋再多一個 既有超額者不強制降級spec §3.1這裡只擋再多一個
*/ */
func (s *Service) assertCanActivate(ctx context.Context, ownerUID int64, exceptWatchID string) error {
return s.assertCanActivateForWatch(ctx, ownerUID, exceptWatchID, false)
}
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error { func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
if productWatch { if productWatch {
return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID)

View File

@ -276,6 +276,10 @@ func (s *MonStore) ListRunPosts(ctx context.Context, ownerUID int64, runID strin
return domain.RunPostPage{}, err return domain.RunPostPage{}, err
} }
page := domain.NormalizePage(requestedPage, requestedSize) page := domain.NormalizePage(requestedPage, requestedSize)
// 與 memory store 一致的可見性屏障run 成功前的候選是暫存資料,不得外流。
if r.Status != domain.RunSucceeded {
return domain.RunPostPage{Run: r, Items: []*domain.Post{}, Pagination: page.WithTotal(0)}, nil
}
q := bson.M{"owner_uid": ownerUID, "run_id": runID} q := bson.M{"owner_uid": ownerUID, "run_id": runID}
total, err := s.posts.CountDocuments(ctx, q) total, err := s.posts.CountDocuments(ctx, q)
if err != nil { if err != nil {

View File

@ -239,13 +239,3 @@ func shortfallReasons(d SearchPipelineDiagnostics) []string {
} }
return reasons return reasons
} }
func normalizePipelineTerms(terms []string) []string {
out := make([]string, 0, len(terms))
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" {
out = append(out, term)
}
}
return dedupeTerms(out)
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"sort"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -860,32 +861,25 @@ func (s *Service) persistSearchHitsWithRun(ctx context.Context, ownerUID int64,
} }
func sortPostsByScore(posts []*domain.Post) { func sortPostsByScore(posts []*domain.Post) {
for i := 0; i < len(posts); i++ { sort.SliceStable(posts, func(i, j int) bool {
for j := i + 1; j < len(posts); j++ {
a, b := posts[i], posts[j] a, b := posts[i], posts[j]
shouldSwap := false
if a.Score != b.Score { if a.Score != b.Score {
shouldSwap = b.Score > a.Score return a.Score > b.Score
} else if (a.PostedAt > 0) != (b.PostedAt > 0) {
shouldSwap = b.PostedAt > 0
} else if a.PostedAt > 0 && a.PostedAt != b.PostedAt {
shouldSwap = b.PostedAt > a.PostedAt
} else if a.CreatedAt != b.CreatedAt {
shouldSwap = b.CreatedAt > a.CreatedAt
} else {
shouldSwap = b.ID > a.ID
} }
if shouldSwap { // 有發文時間者優先其次新到舊ID 收尾保證結果穩定可重現
posts[i], posts[j] = posts[j], posts[i] if (a.PostedAt > 0) != (b.PostedAt > 0) {
return a.PostedAt > 0
} }
if a.PostedAt > 0 && a.PostedAt != b.PostedAt {
return a.PostedAt > b.PostedAt
} }
if a.CreatedAt != b.CreatedAt {
return a.CreatedAt > b.CreatedAt
} }
return a.ID > b.ID
})
} }
// sortPostsByResultTime is kept as a package-local compatibility name for
// older tests/callers; result ordering now intentionally delegates to score.
func sortPostsByResultTime(posts []*domain.Post) { sortPostsByScore(posts) }
func hitsHaveTrack(hits []ThreadSearchResult) bool { func hitsHaveTrack(hits []ThreadSearchResult) bool {
for _, h := range hits { for _, h := range hits {
if h.Track != "" { if h.Track != "" {
@ -982,88 +976,18 @@ func sortHitsByTrackAndPostedAt(hits []ThreadSearchResult) {
return 3 return 3
} }
} }
for i := 0; i < len(hits); i++ { sort.SliceStable(hits, func(i, j int) bool {
for j := i + 1; j < len(hits); j++ {
ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track) ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track)
if rj < ri { if ri != rj {
hits[i], hits[j] = hits[j], hits[i] return ri < rj
continue
}
if rj > ri {
continue
} }
// 同軌內新到舊;沒有發布時間的排最後
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 { if ai == 0 || aj == 0 {
continue return ai != 0
} }
if ai == 0 || (aj > 0 && aj > ai) { return ai > aj
hits[i], hits[j] = hits[j], hits[i] })
}
}
}
}
// sortPostsByTrackAndPostedAt 保留相容:目前先 track 再時間在 sortHits 已做;此函式 no-op 佔位避免誤用。
func sortPostsByTrackAndPostedAt(_ []*domain.Post, _ []ThreadSearchResult) {}
func sortHitsByPostedAt(hits []ThreadSearchResult) {
// newest first; unknown published time last
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
func sortPostsByPostedAt(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
ai := posts[i].PostedAt
if ai == 0 {
ai = posts[i].CreatedAt
}
aj := posts[j].PostedAt
if aj == 0 {
aj = posts[j].CreatedAt
}
if aj > ai {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func sortActivityPostsByMomentum(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
if posts[j].Score > posts[i].Score ||
(posts[j].Score == posts[i].Score && postTime(posts[j]) > postTime(posts[i])) {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func postTime(post *domain.Post) int64 {
if post.PostedAt > 0 {
return post.PostedAt
}
return post.CreatedAt
}
func matchingTerm(text string, terms []string) string {
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
return term
}
}
return ""
} }
func authorFromThreadsURL(raw string) string { func authorFromThreadsURL(raw string) string {

View File

@ -62,13 +62,6 @@ func NewExa() *ExaClient {
} }
} }
// NewExaThreads prefers results from Threads domains.
func NewExaThreads() *ExaClient {
c := NewExa()
c.IncludeDomains = []string{"threads.net", "threads.com"}
return c
}
func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) { func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) {
apiKey = strings.TrimSpace(apiKey) apiKey = strings.TrimSpace(apiKey)
query = strings.TrimSpace(query) query = strings.TrimSpace(query)

View File

@ -856,6 +856,15 @@ func (b *crmRadarOppBridge) GetOpportunity(ctx context.Context, id string) (*rad
return b.Radar.Repo.GetOpportunity(ctx, id) return b.Radar.Repo.GetOpportunity(ctx, id)
} }
func (b *crmRadarOppBridge) ListOpportunities(
ctx context.Context, ownerUID int64, f radarDomain.OpportunityListFilter,
) ([]*radarDomain.Opportunity, int64, error) {
if b == nil || b.Radar == nil {
return nil, 0, radarDomain.ErrNotFound
}
return b.Radar.Repo.ListOpportunities(ctx, ownerUID, f)
}
type radarHealthBridge struct{ Growth *growthUC.Service } type radarHealthBridge struct{ Growth *growthUC.Service }
func (b *radarHealthBridge) WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) { func (b *radarHealthBridge) WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) {

View File

@ -533,6 +533,7 @@ type CrmStatsData struct {
Terms []TermConversionStat `json:"terms"` Terms []TermConversionStat `json:"terms"`
Variants []VariantConversionStat `json:"variants"` Variants []VariantConversionStat `json:"variants"`
Sources []SourceConversionStat `json:"sources"` Sources []SourceConversionStat `json:"sources"`
UnavailableDimensions []string `json:"unavailable_dimensions"`
} }
type CrmStatsReq struct { type CrmStatsReq struct {

View File

@ -800,6 +800,9 @@ export function createLiveCrmRepo(): CrmRepo {
won: num(s.won), won: num(s.won),
insufficient_sample: Boolean(s.insufficient_sample), insufficient_sample: Boolean(s.insufficient_sample),
})), })),
unavailable_dimensions: (Array.isArray(raw.unavailable_dimensions)
? raw.unavailable_dimensions.map(str)
: []) as CrmStats["unavailable_dimensions"],
}; };
}, },
}; };

View File

@ -1303,10 +1303,14 @@ export type SourceConversionStat = {
insufficient_sample: boolean; insufficient_sample: boolean;
}; };
export type CrmStatsDimension = "terms" | "variants" | "sources";
export type CrmStats = { export type CrmStats = {
terms: TermConversionStat[]; terms: TermConversionStat[];
variants: VariantConversionStat[]; variants: VariantConversionStat[];
sources: SourceConversionStat[]; sources: SourceConversionStat[];
/** 尚未實作/無法計算的維度;用來跟「查得到但沒有資料」區分 */
unavailable_dimensions: CrmStatsDimension[];
}; };
export type ContactStageCount = { export type ContactStageCount = {

View File

@ -97,11 +97,11 @@ export function FirstRunProvider({ children }: { children: ReactNode }) {
[pending, allDone, busy, steps, current], [pending, allDone, busy, steps, current],
); );
const gateOffPath = value.active && value.current && !isFirstRunAllowedPath(pathname); const gateStep = value.active && !isFirstRunAllowedPath(pathname) ? value.current : null;
return ( return (
<FirstRunContext.Provider value={value}> <FirstRunContext.Provider value={value}>
{gateOffPath ? <Navigate to={value.current.to} replace /> : children} {gateStep ? <Navigate to={gateStep.to} replace /> : children}
</FirstRunContext.Provider> </FirstRunContext.Provider>
); );
} }

View File

@ -8,9 +8,14 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { formatMoney, formatPlanPrice } from "../lib/i18n/format"; import { formatMoney, formatPlanPrice } from "../lib/i18n/format";
import { translate } from "../lib/i18n/messages"; import {
ensureCatalog,
formatMessage,
getCatalog,
isCatalogLoaded,
} from "../lib/i18n/messages";
import { loadUiPrefs, saveUiPrefs } from "../lib/i18n/prefs"; import { loadUiPrefs, saveUiPrefs } from "../lib/i18n/prefs";
import type { AppCurrency, AppLocale } from "../lib/i18n/types"; import type { AppCurrency, AppLocale, MessageDict } from "../lib/i18n/types";
type I18nContextValue = { type I18nContextValue = {
locale: AppLocale; locale: AppLocale;
@ -31,10 +36,25 @@ export function I18nProvider({ children }: { children: ReactNode }) {
() => loadUiPrefs().currency, () => loadUiPrefs().currency,
); );
const [catalog, setCatalog] = useState<MessageDict>(() => getCatalog(locale));
useEffect(() => { useEffect(() => {
document.documentElement.lang = locale === "en" ? "en" : "zh-Hant"; document.documentElement.lang = locale === "en" ? "en" : "zh-Hant";
}, [locale]); }, [locale]);
// 非預設語系是獨立 chunk先用手上的字典頂著載完再換上正式的。
useEffect(() => {
setCatalog(getCatalog(locale));
if (isCatalogLoaded(locale)) return;
let cancelled = false;
void ensureCatalog(locale).then((dict) => {
if (!cancelled) setCatalog(dict);
});
return () => {
cancelled = true;
};
}, [locale]);
const setLocale = useCallback((next: AppLocale) => { const setLocale = useCallback((next: AppLocale) => {
setLocaleState(next); setLocaleState(next);
saveUiPrefs({ locale: next }); saveUiPrefs({ locale: next });
@ -58,8 +78,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const t = useCallback( const t = useCallback(
(key: string, params?: Record<string, string | number>) => (key: string, params?: Record<string, string | number>) =>
translate(locale, key, params), formatMessage(catalog, key, params),
[locale], [catalog],
); );
const value = useMemo<I18nContextValue>( const value = useMemo<I18nContextValue>(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { ensureCatalog, getCatalog, isCatalogLoaded, translate } from "./messages";
describe("locale catalog loading", () => {
it("ships the default locale and fetches others on demand", async () => {
expect(isCatalogLoaded("zh-TW")).toBe(true);
expect(isCatalogLoaded("en")).toBe(false);
// 還沒載到英文字典時,先用預設語系頂著而不是吐出 key
expect(translate("en", "app.name")).toBe(translate("zh-TW", "app.name"));
await ensureCatalog("en");
expect(isCatalogLoaded("en")).toBe(true);
expect(getCatalog("en")["app.name"]).toBe("Lapras");
expect(translate("en", "app.name")).toBe("Lapras");
});
it("shares one in-flight request across concurrent callers", async () => {
const [a, b] = await Promise.all([ensureCatalog("en"), ensureCatalog("en")]);
expect(a).toBe(b);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
export type AppLocale = "zh-TW" | "en"; export type AppLocale = "zh-TW" | "en";
/** 扁平 key → 字串;{name} 可插值 */
export type MessageDict = Record<string, string>;
export type AppCurrency = "TWD" | "USD" | "JPY" | "EUR" | "HKD"; export type AppCurrency = "TWD" | "USD" | "JPY" | "EUR" | "HKD";
export type LocaleMeta = { export type LocaleMeta = {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { translate } from "./i18n/messages"; import { ensureCatalog, translate } from "./i18n/messages";
import type { AppLocale } from "./i18n/types"; import type { AppLocale } from "./i18n/types";
import { pageHelpKeys, type PageHelpId } from "./pageHelp"; import { pageHelpKeys, type PageHelpId } from "./pageHelp";
@ -35,6 +35,11 @@ const ALL_IDS: PageHelpId[] = [
const LOCALES: AppLocale[] = ["zh-TW", "en"]; const LOCALES: AppLocale[] = ["zh-TW", "en"];
describe("page help catalog completeness", () => { describe("page help catalog completeness", () => {
// 非預設語系是動態載入的;不先載入就只會驗到 zh-TW 後備字串
beforeAll(async () => {
await Promise.all(LOCALES.map((locale) => ensureCatalog(locale)));
});
for (const locale of LOCALES) { for (const locale of LOCALES) {
for (const id of ALL_IDS) { for (const id of ALL_IDS) {
it(`${locale} has full copy for ${id}`, () => { it(`${locale} has full copy for ${id}`, () => {

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { en, zhTW } from "./i18n/messages"; import { en } from "./i18n/catalog.en";
import { zhTW } from "./i18n/catalog.zhTW";
import { sanitizeReviewCopy } from "./reviewCopy"; import { sanitizeReviewCopy } from "./reviewCopy";
const BANNED = /爬蟲|crawler|\bcrawl(?:ing|ed)?\b/i; const BANNED = /爬蟲|crawler|\bcrawl(?:ing|ed)?\b/i;

View File

@ -19,6 +19,8 @@ export function CrmStatsPage() {
const [stats, setStats] = useState<CrmStats | null>(null); const [stats, setStats] = useState<CrmStats | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 後端未實作的維度要與「還沒有資料」分開顯示,否則會誤導使用者
const unavailable = new Set(stats?.unavailable_dimensions ?? []);
const load = useCallback(async () => { const load = useCallback(async () => {
setStats(await repos.crm.getStats()); setStats(await repos.crm.getStats());
@ -60,7 +62,9 @@ export function CrmStatsPage() {
<section className="hb-radar-page"> <section className="hb-radar-page">
<div className="hb-radar-section"> <div className="hb-radar-section">
<h2 className="hb-radar-section__title">{t("crm.stats.terms")}</h2> <h2 className="hb-radar-section__title">{t("crm.stats.terms")}</h2>
{stats.terms.length === 0 ? ( {unavailable.has("terms") ? (
<EmptyState title={t("crm.stats.unavailableDim")} />
) : stats.terms.length === 0 ? (
<EmptyState title={t("crm.stats.emptyDim")} /> <EmptyState title={t("crm.stats.emptyDim")} />
) : ( ) : (
<table className="hb-crm-stats-table"> <table className="hb-crm-stats-table">
@ -96,7 +100,9 @@ export function CrmStatsPage() {
<div className="hb-radar-section"> <div className="hb-radar-section">
<h2 className="hb-radar-section__title">{t("crm.stats.variants")}</h2> <h2 className="hb-radar-section__title">{t("crm.stats.variants")}</h2>
{stats.variants.length === 0 ? ( {unavailable.has("variants") ? (
<EmptyState title={t("crm.stats.unavailableDim")} />
) : stats.variants.length === 0 ? (
<EmptyState title={t("crm.stats.emptyDim")} /> <EmptyState title={t("crm.stats.emptyDim")} />
) : ( ) : (
<table className="hb-crm-stats-table"> <table className="hb-crm-stats-table">

View File

@ -1,10 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repos } from "../data/repos"; import type { Repos } from "../data/repos";
import type { Job, ScoutHomeworkRecord, ScoutRun, ScoutRunBrief } from "../domain/types"; import type { Job, ScoutHomeworkRecord, ScoutRun, ScoutRunBrief } from "../domain/types";
import { I18nProvider } from "../i18n/I18nContext"; import { I18nProvider } from "../i18n/I18nContext";
import { translate } from "../lib/i18n/messages"; import { ensureCatalog, translate } from "../lib/i18n/messages";
import { scoutPostFixture } from "../test/scoutFixtures"; import { scoutPostFixture } from "../test/scoutFixtures";
import { ScoutPage } from "./ScoutPage"; import { ScoutPage } from "./ScoutPage";
@ -125,6 +125,11 @@ function renderPage() {
} }
describe("ScoutPage baseline harness", () => { describe("ScoutPage baseline harness", () => {
// 英文字典是動態載入的,斷言前先確保它到位
beforeAll(async () => {
await ensureCatalog("en");
});
beforeEach(() => { beforeEach(() => {
harness.failLoad = false; harness.failLoad = false;
harness.repos = buildRepos(); harness.repos = buildRepos();

View File

@ -1,13 +1,13 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../data/mock/keys"; import { KEYS } from "../data/mock/keys";
import { I18nProvider } from "../i18n/I18nContext"; import { I18nProvider } from "../i18n/I18nContext";
import { DataDeletionPage } from "./DataDeletionPage"; import { DataDeletionPage } from "./DataDeletionPage";
import { HomePage } from "./HomePage"; import { HomePage } from "./HomePage";
import { PrivacyPage } from "./PrivacyPage"; import { PrivacyPage } from "./PrivacyPage";
import { TermsPage } from "./TermsPage"; import { TermsPage } from "./TermsPage";
import { translate } from "../lib/i18n/messages"; import { ensureCatalog, translate } from "../lib/i18n/messages";
import { PLANS } from "../lib/usageMeter"; import { PLANS } from "../lib/usageMeter";
const authState = vi.hoisted(() => ({ const authState = vi.hoisted(() => ({
@ -71,6 +71,11 @@ const PRIVACY_SECTION_IDS = [
] as const; ] as const;
describe("public intro homepage", () => { describe("public intro homepage", () => {
// 切換語系的斷言會用到英文字典,動態載入需先等它到位
beforeAll(async () => {
await ensureCatalog("en");
});
beforeEach(() => { beforeEach(() => {
authState.member = null; authState.member = null;
authState.loading = false; authState.loading = false;