fix all bug
This commit is contained in:
parent
7defbed562
commit
61a2d332dd
|
|
@ -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 只存平面 RunBrief;run 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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
]);
|
||||||
]);
|
let top: Post[] = topResult.status === "fulfilled" ? topResult.value : [];
|
||||||
} catch (e) {
|
let recent: Post[] = recentResult.status === "fulfilled" ? recentResult.value : [];
|
||||||
// 一軌失敗仍用另一軌
|
const firstFailure =
|
||||||
if (top.length === 0) {
|
topResult.status === "rejected"
|
||||||
try {
|
? topResult.reason
|
||||||
top = await searchTrack(pageTop, query, "top", perTrack);
|
: recentResult.status === "rejected"
|
||||||
} catch {
|
? recentResult.reason
|
||||||
/* keep empty */
|
: undefined;
|
||||||
}
|
if (topResult.status === "rejected" && !isSessionError(topResult.reason)) {
|
||||||
|
try {
|
||||||
|
top = await searchTrack(pageTop, query, "top", perTrack);
|
||||||
|
} catch {
|
||||||
|
/* keep empty */
|
||||||
}
|
}
|
||||||
if (recent.length === 0 && !(e instanceof Error && e.message.includes("session"))) {
|
|
||||||
try {
|
|
||||||
recent = await searchTrack(pageRecent, query, "recent", perTrack);
|
|
||||||
} catch {
|
|
||||||
/* keep empty */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (top.length === 0 && recent.length === 0) throw e;
|
|
||||||
}
|
}
|
||||||
|
if (recentResult.status === "rejected" && !isSessionError(recentResult.reason)) {
|
||||||
|
try {
|
||||||
|
recent = await searchTrack(pageRecent, query, "recent", perTrack);
|
||||||
|
} catch {
|
||||||
|
/* keep empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[
|
||||||
|
{ "dropIndexes": "jobs", "index": "radar_sweep_job_ref_unique" }
|
||||||
|
]
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil {
|
brief, cached := briefs[f.ContactID]
|
||||||
p.Contact = types.ContactBrief{
|
if !cached {
|
||||||
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
|
if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil {
|
||||||
DisplayName: c.DisplayName, Stage: c.Stage,
|
brief = types.ContactBrief{
|
||||||
|
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ const (
|
||||||
FollowUpEscalated = "escalated"
|
FollowUpEscalated = "escalated"
|
||||||
|
|
||||||
DefaultFollowUpDays = 3
|
DefaultFollowUpDays = 3
|
||||||
|
// MaxFollowUpNotifications:通知達此次數仍無動作即 escalated(spec FU-03)。
|
||||||
|
MaxFollowUpNotifications = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type Contact struct {
|
type Contact struct {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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-01/FU-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
// DimensionTerms/DimensionSources 需要 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 轉換統計;from/to 任一邊為 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 nil, nil
|
return stored, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleManualRadarSweep starts an extra patrol now.
|
// ScheduleManualRadarSweep starts an extra patrol now.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 tokens;map 迭代無序,排序後再併入以維持輸出穩定
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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,13 +91,25 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -70,17 +70,13 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
assertCanActivate 是「變成 active」的兩道閘(SP-01、RW-01)。
|
assertCanActivateForWatch 是「變成 active」的兩道閘(SP-01、RW-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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
if a.Score != b.Score {
|
||||||
shouldSwap := false
|
return a.Score > b.Score
|
||||||
if a.Score != b.Score {
|
|
||||||
shouldSwap = b.Score > a.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 {
|
|
||||||
posts[i], posts[j] = posts[j], posts[i]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// 有發文時間者優先,其次新到舊;ID 收尾保證結果穩定可重現
|
||||||
|
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 ri != rj {
|
||||||
if rj < ri {
|
return ri < rj
|
||||||
hits[i], hits[j] = hits[j], hits[i]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rj > ri {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// 同軌內新到舊;沒有發布時間的排最後
|
||||||
}
|
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
|
||||||
|
if ai == 0 || aj == 0 {
|
||||||
// sortPostsByTrackAndPostedAt 保留相容:目前先 track 再時間在 sortHits 已做;此函式 no-op 佔位避免誤用。
|
return ai != 0
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return ai > aj
|
||||||
}
|
})
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -530,9 +530,10 @@ type CrmConversionData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CrmStatsData struct {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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}`, () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue