// 「采集采购」模块的业务逻辑(#19)。 // // `tasks` 是一张表,用 task_type 区分采集和采购。这一层的核心工作是把 // 两种业务字段完全不同的任务,统一翻成界面能直接显示的东西——尤其是 // 「目标」这一列:拼接逻辑必须放在这里,模板只负责显示, // 散在模板里的话谁都维护不了。 // // 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。 package service import ( "database/sql" "encoding/json" "fmt" "sort" "strings" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) // ---------- 界面文字:类型 ---------- var taskTypeTexts = map[model.TaskType]string{ model.TaskCollect: "采集", model.TaskPurchase: "采购", } // TaskTypeOption 是类型筛选下拉框的一项。 type TaskTypeOption struct { Value string // 空串表示"全部" Text string } // TaskTypeOptions 返回类型筛选下拉框的全部选项。 func TaskTypeOptions() []TaskTypeOption { return []TaskTypeOption{ {"", "全部"}, {string(model.TaskCollect), taskTypeTexts[model.TaskCollect]}, {string(model.TaskPurchase), taskTypeTexts[model.TaskPurchase]}, } } // ParseTaskType 校验类型筛选参数。认不出的一律当"全部",不报错—— // 地址栏参数是用户可以随便改的,不值得为它弹错误页。 func ParseTaskType(s string) model.TaskType { t := model.TaskType(strings.TrimSpace(s)) if _, ok := taskTypeTexts[t]; ok { return t } return "" } func taskTypeText(t model.TaskType) string { if s, ok := taskTypeTexts[t]; ok { return s } return string(t) } func taskExecutionModeText(taskType model.TaskType, mode model.TaskExecutionMode) string { if taskType != model.TaskPurchase { return "采集" } if mode == model.TaskExecutionLive { return "真实下单(不支付)" } return "采购演练" } // ---------- 界面文字:状态 ---------- // taskStatusOrder 定下状态在筛选框和底部状态条里的显示顺序, // 跟 docs/admin/01-requirements.md §6.2 的表格顺序保持一致。 var taskStatusOrder = []model.TaskStatus{ model.TaskPending, model.TaskAssigned, model.TaskClaimed, model.TaskSucceeded, model.TaskManualReview, model.TaskFailed, model.TaskCancelled, } var taskStatusTexts = map[model.TaskStatus]string{ model.TaskPending: "待分配", model.TaskAssigned: "待领取", model.TaskClaimed: "已领取", model.TaskSucceeded: "成功", model.TaskManualReview: "需人工", model.TaskFailed: "失败", model.TaskCancelled: "已取消", } // TaskStatusOption 是状态筛选下拉框的一项。 type TaskStatusOption struct { Value string Text string } // TaskStatusOptions 返回状态筛选下拉框的全部选项:全部 + 7 个状态。 func TaskStatusOptions() []TaskStatusOption { opts := make([]TaskStatusOption, 0, len(taskStatusOrder)+1) opts = append(opts, TaskStatusOption{"", "全部"}) for _, s := range taskStatusOrder { opts = append(opts, TaskStatusOption{string(s), taskStatusTexts[s]}) } return opts } // ParseTaskStatus 校验状态筛选参数,规则同 ParseTaskType。 func ParseTaskStatus(s string) model.TaskStatus { st := model.TaskStatus(strings.TrimSpace(s)) if _, ok := taskStatusTexts[st]; ok { return st } return "" } func taskStatusText(s model.TaskStatus) string { if t, ok := taskStatusTexts[s]; ok { return t } return string(s) } // isTaskWarn 判断这一行要不要标黄提醒。 // 失败和需人工都是操作员该去看一眼的状态,跟 PDD 商品页对失败行的处理一致。 func isTaskWarn(s model.TaskStatus) bool { return s == model.TaskFailed || s == model.TaskManualReview } // clientText 是「分配客户端」列/字段的统一显示规则。 // // `[必须]` 无主任务显示 `—`,不是空白也不是 ``——#17 之后 // 采集任务默认无主,这一列会大量为空,显示不出来会让人以为页面坏了。 func clientText(assignedClient string) string { if strings.TrimSpace(assignedClient) == "" { return placeholder } return assignedClient } // clientDisplayName 优先使用操作员看得懂的客户端名称。 // 名称为空或客户端记录已删除时回退稳定 ID,不能把已分配任务显示成空白。 func clientDisplayName(clientID, clientName string) string { if name := strings.TrimSpace(clientName); name != "" { return name } return clientText(clientID) } // clientDisplayTitle 给列表截断单元格提供完整名称和稳定 ID。 func clientDisplayTitle(clientID, clientName string) string { name := strings.TrimSpace(clientName) id := strings.TrimSpace(clientID) if name != "" && id != "" { return name + "(" + id + ")" } return clientDisplayName(id, name) } func optionalDisplayText(value string) string { if value = strings.TrimSpace(value); value != "" { return value } return placeholder } // ---------- 目标列 ---------- // buildTarget 把一行任务的业务字段拼成「目标」列要显示的一句话。 // // `[必须]` 拼接逻辑放在这里,不要散到模板里——见 #19。 func buildTarget(r repository.TaskListRow) string { if r.TaskType == model.TaskPurchase { return buildPurchaseTarget(r) } // 默认按采集任务处理:目前只有 collect/purchase 两种类型, // 万一将来出现认不出的类型,退回采集的展示方式好过什么都不显示。 return buildCollectTarget(r) } // buildCollectTarget 采集任务的目标:`PDD `, // 能 join 到未删除商品的标题时追加显示,join 不到或商品已软删除 // 时 PddTitle 本来就是空,天然退回只显示 goods_id。 func buildCollectTarget(r repository.TaskListRow) string { id := r.PddGoodsID if id == "" { id = placeholder } target := "PDD " + id if r.PddTitle != "" { target += " · " + r.PddTitle } return target } // buildPurchaseTarget 采购任务的目标:`<订单号> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。 // 每一段都可能缺,缺了就显示占位符,不静默拼出一句不完整的话。 func buildPurchaseTarget(r repository.TaskListRow) string { orderNo := r.OrderNo if orderNo == "" { orderNo = placeholder } parts := []string{ orderNo, specText(r.PddOptions), quantityText(r.Quantity), priceLimitText(r.MaxPriceCent), } return strings.Join(parts, " · ") } // specText 把 pdd_options(形如 {"color":"黑色","size":"M码"})翻成 // "黑色/M码" 这样的一句话。 // // 按 key 排序拼接,不是按 JSON 原始顺序——JSON 对象本来就无序, // 直接按解析出的 map 遍历会导致同一条任务每次刷新页面顺序都不一样。 func specText(rawOptions string) string { if strings.TrimSpace(rawOptions) == "" { return placeholder } var options map[string]string if err := json.Unmarshal([]byte(rawOptions), &options); err != nil || len(options) == 0 { return placeholder } keys := make([]string, 0, len(options)) for k := range options { keys = append(keys, k) } sort.Strings(keys) values := make([]string, 0, len(keys)) for _, k := range keys { values = append(values, options[k]) } return strings.Join(values, "/") } // quantityText 把数量翻成"2件"。quantity 为 0(数据库约束下等价于 NULL) // 时显示占位符,不显示"0件"——那会被误读成"数量是 0"。 func quantityText(quantity int) string { if quantity <= 0 { return placeholder + "件" } return fmt.Sprintf("%d件", quantity) } // priceLimitText 把价格上限翻成"≤¥42.00"。 // // `[必须]` 复用 formatPriceCent,不要另写一份价格格式化逻辑(见 #19)。 // cent <= 0 时(数据库约束下等价于 NULL)显示占位符,不显示 ≤¥0.00—— // 那会让人以为上限是 0 元。 func priceLimitText(cent int64) string { if cent <= 0 { return "≤" + placeholder } return "≤" + formatPriceCent(¢) } // ---------- 列表 ---------- // TaskView 是列表页一行要显示的全部内容,全部已经是字符串。 type TaskView struct { TaskID string TypeText string ExecutionModeText string Target string PddShopText string StatusText string IsWarn bool // 失败 / 需人工,标黄提醒 ClientText string ClientTitle string CreatorText string UpdatedAt string } const TaskCreatorHistoryValue = "__history__" // TaskCreatorOption 是管理员创建人筛选的一项。 type TaskCreatorOption struct { Value string Text string } // TaskCreatorOptions 返回全部账号(含已禁用)以及存量历史任务选项。 func TaskCreatorOptions(db *sql.DB, actor *model.User) ([]TaskCreatorOption, error) { if actor == nil { return nil, ErrUnauthenticated } if !actor.IsAdmin() { return nil, nil } users, err := repository.ListTaskCreatorUsers(db) if err != nil { return nil, err } options := []TaskCreatorOption{{Value: "", Text: "全部创建人"}, {Value: TaskCreatorHistoryValue, Text: "历史任务"}} for _, user := range users { text := user.Username if user.Status == model.UserDisabled { text += "(已禁用)" } options = append(options, TaskCreatorOption{Value: user.UserID, Text: text}) } return options, nil } // TaskListResult 是列表页要的全部数据。 type TaskListResult struct { Rows []TaskView Counts map[model.TaskStatus]int Total int // 当前筛选下的总数(= Counts 求和),不是全库总数 Page int TotalPages int // IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务 // (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时 // Rows 为空则是"当前筛选没有结果"——两种空状态文案不同,模板据此区分。 IsFiltered bool } // ListTasksView 查列表并把每一行翻成界面文字。 // // `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个** // filter,见 #19。 func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) { counts, err := repository.CountTasksByStatus(db, filter) if err != nil { return nil, err } total := 0 for _, n := range counts { total += n } totalPages := TotalPages(total) page := ClampPage(requestedPage, totalPages) rows, err := repository.ListTasks(db, filter, PageSize, (page-1)*PageSize) if err != nil { return nil, err } result := &TaskListResult{ Rows: make([]TaskView, 0, len(rows)), Counts: counts, Total: total, Page: page, TotalPages: totalPages, IsFiltered: filter.Type != "" || filter.Status != "" || strings.TrimSpace(filter.Keyword) != "" || filter.CreatorUserID != "" || filter.CreatorHistory, } for _, r := range rows { result.Rows = append(result.Rows, TaskView{ TaskID: r.TaskID, TypeText: taskTypeText(r.TaskType), ExecutionModeText: taskExecutionModeText(r.TaskType, r.ExecutionMode), Target: buildTarget(r), PddShopText: optionalDisplayText(r.PddShopName), StatusText: taskStatusText(r.Status), IsWarn: isTaskWarn(r.Status), ClientText: clientDisplayName(r.AssignedClient, r.ClientName), ClientTitle: clientDisplayTitle(r.AssignedClient, r.ClientName), CreatorText: taskCreatorText(r.CreatedByUsername), UpdatedAt: formatLocalTime(r.UpdatedAt), }) } return result, nil } func taskCreatorText(username string) string { if strings.TrimSpace(username) == "" { return "历史任务" } return username } // ListTasksViewForUser 在服务端固定当前账号范围,忽略采购员伪造的创建人筛选。 func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskFilter, creatorValue string, requestedPage int) (*TaskListResult, error) { visibleUserID, err := visibleClientUserID(actor) if err != nil { return nil, err } filter.VisibleUserID = visibleUserID filter.CreatorUserID = "" filter.CreatorHistory = false if actor.IsAdmin() { creatorValue = strings.TrimSpace(creatorValue) if creatorValue == TaskCreatorHistoryValue { filter.CreatorHistory = true } else if creatorValue != "" { filter.CreatorUserID = creatorValue } } return ListTasksView(db, filter, requestedPage) } // StatusLine 拼底部状态条,形如: // // 共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0 // // `[必须]` 这里的每一个数字都来自当前筛选下的 Counts,不是全库统计—— // 筛了「采集」就只统计采集任务,否则数字和表格对不上,见 #19。 func (r *TaskListResult) StatusLine() string { parts := []string{fmt.Sprintf("共 %d 条", r.Total)} for _, s := range taskStatusOrder { parts = append(parts, fmt.Sprintf("%s %d", taskStatusText(s), r.Counts[s])) } return fmt.Sprintf("%s · 第 %d/%d 页", strings.Join(parts, " · "), r.Page, r.TotalPages) } // DeleteTasks 批量删除任务,返回实际删掉的条数。 func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) { return deleteTasksInScope(db, dedupe(taskIDs), "", false) } // DeleteTasksForUser 保证整批删除要么全部在当前范围内成功,要么全部回滚。 func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64, error) { visibleUserID, err := visibleClientUserID(actor) if err != nil { return 0, err } return deleteTasksInScope(db, dedupe(taskIDs), visibleUserID, true) } func deleteTasksInScope(db *sql.DB, ids []string, visibleUserID string, enforceExactScope bool) (int64, error) { if len(ids) == 0 { return 0, nil } tx, err := db.Begin() if err != nil { return 0, fmt.Errorf("开始删除任务事务失败: %w", err) } defer tx.Rollback() goodsIDs, err := repository.ListCollectTaskGoodsIDsInScope(tx, ids, visibleUserID) if err != nil { return 0, err } n, err := repository.DeleteTasksInScope(tx, ids, visibleUserID) if err != nil { return 0, err } if enforceExactScope && n != int64(len(ids)) { return 0, ErrTaskNotVisible } for _, goodsID := range goodsIDs { if _, err := repository.ResetCollectingIfNoActiveTask(tx, goodsID); err != nil { return 0, err } } if err := tx.Commit(); err != nil { return 0, fmt.Errorf("提交删除任务事务失败: %w", err) } return n, nil } // ---------- 详情弹窗 ---------- // resultDataLimit 是弹窗里展开显示 result_data 的最大字符数。 // 客户端提交的完整结果可能很大,超过这个长度就截断, // 避免一次性把几十 KB 的 JSON 糊在页面上。 const resultDataLimit = 4000 // TaskDetailView 是双击弹窗要显示的全部内容。 // // `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。 type TaskDetailView struct { TaskID string TypeText string ExecutionModeText string StatusText string ClientText string ClientIDText string CreatorText string ClaimedAt string FinishedAt string PddGoodsURL string PddShopText string // IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏, // 不能显示空行——见 #19。 IsPurchase bool SpecText string QuantityText string PriceLimitText string // PDD 核单结果只在采购任务详情显示。付款状态是 Client 核单上报时的快照, // 不是 Admin 对 PDD 的实时查询结果。 HasPddOrder bool PddOrderNo string PddOrderedAt string PddPaymentStatusText string PddOrderResultText string HasError bool ErrorCode string ErrorMessage string HasResult bool ResultData string ResultTruncated bool } // GetTaskDetail 读一条任务的完整信息,翻成弹窗要显示的文字。 // 任务不存在返回 (nil, nil)。 func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) { t, err := repository.GetTask(db, taskID) if err != nil || t == nil { return nil, err } displayContext, err := repository.GetTaskDisplayContext(db, taskID) if err != nil { return nil, err } v := &TaskDetailView{ TaskID: t.TaskID, TypeText: taskTypeText(t.TaskType), ExecutionModeText: taskExecutionModeText(t.TaskType, t.ExecutionMode), StatusText: taskStatusText(t.Status), ClientText: clientDisplayName(t.AssignedClient, displayContext.ClientName), ClientIDText: clientText(t.AssignedClient), CreatorText: taskCreatorText(displayContext.CreatorUsername), ClaimedAt: formatLocalTime(t.ClaimedAt), FinishedAt: formatLocalTime(t.FinishedAt), PddGoodsURL: t.PddGoodsURL, PddShopText: optionalDisplayText(displayContext.PddShopName), IsPurchase: t.TaskType == model.TaskPurchase, } if v.IsPurchase { v.SpecText = specText(t.PddOptions) v.QuantityText = quantityText(t.Quantity) v.PriceLimitText = priceLimitText(t.MaxPriceCent) setPddOrderResult(v, *t) } if t.ErrorCode != "" || t.ErrorMessage != "" { v.HasError = true v.ErrorCode = orPlaceholder(t.ErrorCode) v.ErrorMessage = orPlaceholder(t.ErrorMessage) } if strings.TrimSpace(t.ResultData) != "" { v.HasResult = true v.ResultData, v.ResultTruncated = truncateResultData(t.ResultData) } return v, nil } // GetTaskDetailForUser 对采购员隐藏其他人和历史任务,统一表现为不存在。 func GetTaskDetailForUser(db *sql.DB, actor *model.User, taskID string) (*TaskDetailView, error) { visibleUserID, err := visibleClientUserID(actor) if err != nil { return nil, err } visible, err := repository.TaskVisibleToUser(db, taskID, visibleUserID) if err != nil || !visible { return nil, err } return GetTaskDetail(db, taskID) } // purchaseResult 只声明详情页需要展示的字段,避免把 Client 的完整结果结构 // 复制到 Admin。其余原始字段仍保存在 result_data 并可在详情底部展开查看。 type purchaseResult struct { Purchase *struct { OrderNo string `json:"order_no"` OrderedAt string `json:"ordered_at"` PaymentStatus string `json:"payment_status"` MatchStatus string `json:"match_status"` OrderSubmitted bool `json:"order_submitted"` } `json:"purchase"` } // setPddOrderResult 把采购结果翻成模板可直接显示的文字。 // 解析失败只影响结构化摘要,不能阻止详情页打开,也不能隐藏原始 result_data。 func setPddOrderResult(v *TaskDetailView, task model.Task) { raw := strings.TrimSpace(task.ResultData) if raw == "" { switch task.Status { case model.TaskManualReview: v.PddOrderResultText = "订单结果不确定,请结合错误信息人工核对。" case model.TaskFailed, model.TaskCancelled: v.PddOrderResultText = "任务未产生可展示的 PDD 核单结果。" default: v.PddOrderResultText = "等待 Client 核单上报。" } return } var result purchaseResult if err := json.Unmarshal([]byte(raw), &result); err != nil || result.Purchase == nil { v.PddOrderResultText = "采购结果格式异常,请展开完整结果进行诊断。" return } p := result.Purchase if p.MatchStatus != "matched" || !p.OrderSubmitted { v.PddOrderResultText = "本次结果没有已匹配的 PDD 订单。" return } if strings.TrimSpace(p.OrderNo) == "" || p.PaymentStatus != "unpaid" { v.PddOrderResultText = "采购结果格式异常,请展开完整结果进行诊断。" return } orderedAt, ok := model.ParseISO(strings.TrimSpace(p.OrderedAt)) if !ok { v.PddOrderResultText = "采购结果中的下单时间格式异常,请展开完整结果进行诊断。" return } v.HasPddOrder = true v.PddOrderNo = strings.TrimSpace(p.OrderNo) v.PddOrderedAt = orderedAt.Local().Format("2006-01-02 15:04") v.PddPaymentStatusText = "未付款(Client 上报时)" v.PddOrderResultText = "Admin 不会查询 PDD 实时付款状态。" } func orPlaceholder(s string) string { if s == "" { return placeholder } return s } // truncateResultData 超过 resultDataLimit 个字符就截断, // 并如实告诉操作员被截断了(不能悄悄截断,那会让人以为结果就这么短)。 func truncateResultData(raw string) (string, bool) { r := []rune(raw) if len(r) <= resultDataLimit { return raw, false } return string(r[:resultDataLimit]), true }