feat: 采集采购页,采集与采购统一展示 (#19)

原来的采购任务页是骨架:var rows []gin.H 从不查库,页面永远为空;
TODO 写的还是只查 task_type = 'purchase'。所以 #18 建出来的采集任务
在界面上哪儿都看不到——用户点完「创建采集任务」只能盯着
collect_status 猜。

模块名定为「采集采购」(用户指定),直接点出这页装的是哪两类任务,
比泛称「任务」更能让人一眼知道点进去看什么。路由 /tasks 不变,
改路由会让已有书签和文档链接全失效,没有收益。

列不按类型并列——两种任务字段完全不同,并列会让采集任务行一半是空列。
改成固定列 + 一列「目标」把业务信息概括成一句话:
  采集  PDD 737116531267
  采购  SO-001 · M/黑色 · 2件 · ≤¥42.00
拼接逻辑在 service 层,模板只负责显示。

统计和列表共用同一个筛选条件拼装函数。分开写的话总有一天会忘了
给统计也加条件,数字和表格对不上,操作员会以为页面坏了。

无主任务的客户端列显示「—」。#17 之后采集任务默认无主,这列会大量为空。

详情弹窗只读,采购专有字段(数量、价格上限、目标规格)在采集任务里
整段不出现,不显示空行。复用 #18 的弹窗机制,app.js 无需改动。

顺带清掉 PDD 页加进来之后一直没跟上的模块计数:多处「四个模块/四个页面」
改成五个。其中 06-quality-security.md 那两处是验证清单,
照着做的人只会测四个页面,PDD 页永远不在回归范围里。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-07 15:16:05 +08:00
co-authored by Claude Opus 5
parent c07fe56ac3
commit f0d7da37cf
18 changed files with 1423 additions and 79 deletions
+391
View File
@@ -0,0 +1,391 @@
// 「采集采购」模块的业务逻辑(#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)
}
// ---------- 界面文字:状态 ----------
// 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 是「分配客户端」列/字段的统一显示规则。
//
// `[必须]` 无主任务显示 `—`,不是空白也不是 `<nil>`——#17 之后
// 采集任务默认无主,这一列会大量为空,显示不出来会让人以为页面坏了。
func clientText(assignedClient string) string {
if assignedClient == "" {
return placeholder
}
return assignedClient
}
// ---------- 目标列 ----------
// buildTarget 把一行任务的业务字段拼成「目标」列要显示的一句话。
//
// `[必须]` 拼接逻辑放在这里,不要散到模板里——见 #19。
func buildTarget(r repository.TaskListRow) string {
if r.TaskType == model.TaskPurchase {
return buildPurchaseTarget(r)
}
// 默认按采集任务处理:目前只有 collect/purchase 两种类型,
// 万一将来出现认不出的类型,退回采集的展示方式好过什么都不显示。
return buildCollectTarget(r)
}
// buildCollectTarget 采集任务的目标:`PDD <goods_id>`,
// 能 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(&cent)
}
// ---------- 列表 ----------
// TaskView 是列表页一行要显示的全部内容,全部已经是字符串。
type TaskView struct {
TaskID string
TypeText string
Target string
StatusText string
IsWarn bool // 失败 / 需人工,标黄提醒
ClientText string
UpdatedAt string
}
// TaskListResult 是列表页要的全部数据。
type TaskListResult struct {
Rows []TaskView
Counts map[model.TaskStatus]int
Total int // 当前筛选下的总数(= Counts 求和),不是全库总数
// IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务
// (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时
// Rows 为空则是"当前筛选没有结果"——两种空状态文案不同,模板据此区分。
IsFiltered bool
}
// ListTasksView 查列表并把每一行翻成界面文字。
//
// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个**
// filter,见 #19。
func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, error) {
rows, err := repository.ListTasks(db, filter)
if err != nil {
return nil, err
}
counts, err := repository.CountTasksByStatus(db, filter)
if err != nil {
return nil, err
}
result := &TaskListResult{
Rows: make([]TaskView, 0, len(rows)),
Counts: counts,
IsFiltered: filter.Type != "" || filter.Status != "" ||
strings.TrimSpace(filter.Keyword) != "",
}
for _, n := range counts {
result.Total += n
}
for _, r := range rows {
result.Rows = append(result.Rows, TaskView{
TaskID: r.TaskID,
TypeText: taskTypeText(r.TaskType),
Target: buildTarget(r),
StatusText: taskStatusText(r.Status),
IsWarn: isTaskWarn(r.Status),
ClientText: clientText(r.AssignedClient),
UpdatedAt: formatLocalTime(r.UpdatedAt),
})
}
return result, nil
}
// 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 strings.Join(parts, " · ")
}
// DeleteTasks 批量删除任务,返回实际删掉的条数。
func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) {
return repository.DeleteTasks(db, dedupe(taskIDs))
}
// ---------- 详情弹窗 ----------
// resultDataLimit 是弹窗里展开显示 result_data 的最大字符数。
// 客户端提交的完整结果可能很大,超过这个长度就截断,
// 避免一次性把几十 KB 的 JSON 糊在页面上。
const resultDataLimit = 4000
// TaskDetailView 是双击弹窗要显示的全部内容。
//
// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。
type TaskDetailView struct {
TaskID string
TypeText string
StatusText string
ClientText string
ClaimedAt string
FinishedAt string
PddGoodsURL string
// IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏,
// 不能显示空行——见 #19。
IsPurchase bool
SpecText string
QuantityText string
PriceLimitText 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
}
v := &TaskDetailView{
TaskID: t.TaskID,
TypeText: taskTypeText(t.TaskType),
StatusText: taskStatusText(t.Status),
ClientText: clientText(t.AssignedClient),
ClaimedAt: formatLocalTime(t.ClaimedAt),
FinishedAt: formatLocalTime(t.FinishedAt),
PddGoodsURL: t.PddGoodsURL,
IsPurchase: t.TaskType == model.TaskPurchase,
}
if v.IsPurchase {
v.SpecText = specText(t.PddOptions)
v.QuantityText = quantityText(t.Quantity)
v.PriceLimitText = priceLimitText(t.MaxPriceCent)
}
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
}
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
}
+455
View File
@@ -0,0 +1,455 @@
package service
import (
"database/sql"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// insertTestTask 插一条任务,字段按需覆盖默认值,供本文件的用例使用。
// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值,
// 这里需要覆盖类型、状态、options 等更多维度。
type testTaskParams struct {
taskID string
taskType model.TaskType
status model.TaskStatus
assignedClient string
orderNo string
pddGoodsID string
pddOptions string
quantity int
maxPriceCent int64
errorCode string
errorMessage string
resultData string
claimedAt string
finishedAt string
}
func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
t.Helper()
now := model.NowISO()
var assigned, orderNo, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any
if p.assignedClient != "" {
assigned = p.assignedClient
}
if p.orderNo != "" {
orderNo = p.orderNo
}
if p.pddGoodsID != "" {
pddGoodsID = p.pddGoodsID
}
if p.pddOptions != "" {
pddOptions = p.pddOptions
}
if p.errorCode != "" {
errCode = p.errorCode
}
if p.errorMessage != "" {
errMsg = p.errorMessage
}
if p.resultData != "" {
resultData = p.resultData
}
if p.claimedAt != "" {
claimedAt = p.claimedAt
}
if p.finishedAt != "" {
finishedAt = p.finishedAt
}
var quantity, maxPrice any
if p.quantity > 0 {
quantity = p.quantity
}
if p.maxPriceCent > 0 {
maxPrice = p.maxPriceCent
}
_, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at,
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
error_code, error_message, result_data, finished_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
?, ?, ?, ?, ?, ?, ?, ?)`,
p.taskID, string(p.taskType), string(p.status), assigned, claimedAt,
orderNo, pddGoodsID, pddOptions,
quantity, maxPrice,
errCode, errMsg, resultData, finishedAt,
now, now)
if err != nil {
t.Fatalf("插入测试任务 %s 失败: %v", p.taskID, err)
}
}
// ── 列表:同时显示采集和采购 ──────────────────────────
func TestListTasksView_同时显示采集和采购(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
result, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 2 {
t.Fatalf("应该看到 2 条任务,实际 %d 条", len(result.Rows))
}
}
// ── 类型 / 状态 / 关键词筛选 ──────────────────────────
func TestListTasksView_类型筛选生效(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
result, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-1" {
t.Fatalf("筛选采集应该只剩 COL-1,实际 %+v", result.Rows)
}
if !result.IsFiltered {
t.Error("有类型筛选时 IsFiltered 应该为 true")
}
}
func TestListTasksView_状态筛选生效(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"})
result, err := ListTasksView(db, repository.TaskFilter{Status: model.TaskSucceeded})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-2" {
t.Fatalf("筛选'成功'应该只剩 COL-2,实际 %+v", result.Rows)
}
}
func TestListTasksView_关键词同时匹配任务编号订单号PDD商品ID(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-ABC", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-XYZ"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-OTHER"})
// 按任务编号命中
r, err := ListTasksView(db, repository.TaskFilter{Keyword: "ABC"})
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
t.Fatalf("按任务编号搜索失败: err=%v rows=%+v", err, r)
}
// 按订单号命中
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "XYZ"})
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "PUR-1" {
t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r)
}
// 按 PDD 商品 ID 命中
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "737116531267"})
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r)
}
}
// ── 目标列 ────────────────────────────────────────────
func TestListTasksView_目标列_采集任务显示PDD商品ID(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
want := "PDD 737116531267"
if r.Rows[0].Target != want {
t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want)
}
}
func TestListTasksView_目标列_采集任务能join到标题时追加显示(t *testing.T) {
db := newTestDB(t)
if _, err := db.Exec(`
INSERT INTO pdd_products (goods_id, url, title, collect_status, created_at, updated_at)
VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267',
'秋冬保暖加绒卫衣', 'collected', ?, ?)`, model.NowISO(), model.NowISO()); err != nil {
t.Fatalf("插入 PDD 商品失败: %v", err)
}
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
want := "PDD 737116531267 · 秋冬保暖加绒卫衣"
if r.Rows[0].Target != want {
t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want)
}
}
func TestListTasksView_目标列_商品已软删除时退回显示商品ID(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO pdd_products (goods_id, url, title, collect_status, deleted_at, created_at, updated_at)
VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267',
'秋冬保暖加绒卫衣', 'collected', ?, ?, ?)`, now, now, now); err != nil {
t.Fatalf("插入 PDD 商品失败: %v", err)
}
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
want := "PDD 737116531267"
if r.Rows[0].Target != want {
t.Errorf("商品已软删除时应退回只显示商品 ID,实际 %q", r.Rows[0].Target)
}
}
func TestListTasksView_目标列_采购任务显示订单号规格数量价格上限(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{
taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned,
orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`,
quantity: 2, maxPriceCent: 4200,
})
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
want := "SO-001 · 黑色/M · 2件 · ≤¥42.00"
if r.Rows[0].Target != want {
t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want)
}
}
// ── 客户端列 ──────────────────────────────────────────
func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if r.Rows[0].ClientText != "—" {
t.Errorf("无主任务客户端列应该显示占位符,实际 %q", r.Rows[0].ClientText)
}
}
// ── 统计跟随筛选 ──────────────────────────────────────
func TestListTasksView_统计跟随当前筛选(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-2"})
all, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if all.Total != 4 {
t.Fatalf("不筛选时总数应该是 4,实际 %d", all.Total)
}
collectOnly, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if collectOnly.Total != 2 {
t.Fatalf("筛选采集后总数应该是 2,实际 %d", collectOnly.Total)
}
if collectOnly.Counts[model.TaskAssigned] != 0 {
t.Errorf("筛选采集后不该统计到采购任务的'待领取',实际 %d", collectOnly.Counts[model.TaskAssigned])
}
if collectOnly.Counts[model.TaskPending] != 1 || collectOnly.Counts[model.TaskSucceeded] != 1 {
t.Errorf("筛选采集后的状态分布不对: %+v", collectOnly.Counts)
}
}
// ── 空状态 ────────────────────────────────────────────
func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) {
db := newTestDB(t)
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(r.Rows) != 0 {
t.Fatalf("空库应该查出 0 条")
}
if r.IsFiltered {
t.Error("没有任何筛选条件时 IsFiltered 应该为 false,模板据此显示'还没有任务'而不是'筛选无结果'")
}
}
func TestListTasksView_筛选无结果时IsFiltered为true(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
r, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskPurchase})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(r.Rows) != 0 {
t.Fatalf("筛选采购应该查出 0 条")
}
if !r.IsFiltered {
t.Error("筛选出 0 条时 IsFiltered 应该为 true,模板据此显示'筛选无结果'而不是'还没有任务'")
}
}
// ── 详情弹窗 ──────────────────────────────────────────
func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{
taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskClaimed,
orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`,
quantity: 2, maxPriceCent: 4200, assignedClient: "client-001",
claimedAt: model.NowISO(),
})
d, err := GetTaskDetail(db, "PUR-1")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if d == nil {
t.Fatal("任务应该存在")
}
if !d.IsPurchase {
t.Error("采购任务 IsPurchase 应该为 true")
}
if d.SpecText != "黑色/M" || d.QuantityText != "2件" || d.PriceLimitText != "≤¥42.00" {
t.Errorf("采购专有字段不对: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText)
}
if d.ClientText != "client-001" {
t.Errorf("分配客户端 = %q, want client-001", d.ClientText)
}
}
func TestGetTaskDetail_采集任务不带采购专有字段(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
d, err := GetTaskDetail(db, "COL-1")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if d.IsPurchase {
t.Error("采集任务 IsPurchase 应该为 false,模板据此隐藏采购专有字段")
}
if d.SpecText != "" || d.QuantityText != "" || d.PriceLimitText != "" {
t.Errorf("采集任务不该填充采购专有字段: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText)
}
}
func TestGetTaskDetail_有错误信息时显示(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{
taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskFailed,
pddGoodsID: "111", errorCode: "PDD_PAGE_TIMEOUT", errorMessage: "商品页加载超时",
})
d, err := GetTaskDetail(db, "COL-1")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if !d.HasError {
t.Fatal("应该检测到错误信息")
}
if d.ErrorCode != "PDD_PAGE_TIMEOUT" || d.ErrorMessage != "商品页加载超时" {
t.Errorf("错误信息不对: code=%q msg=%q", d.ErrorCode, d.ErrorMessage)
}
}
func TestGetTaskDetail_没有错误信息时不显示错误段(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
d, err := GetTaskDetail(db, "COL-1")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if d.HasError {
t.Error("没有错误码和错误说明时 HasError 应该为 false")
}
}
func TestGetTaskDetail_任务不存在返回nil(t *testing.T) {
db := newTestDB(t)
d, err := GetTaskDetail(db, "NOT-EXIST")
if err != nil {
t.Fatalf("不该报错: %v", err)
}
if d != nil {
t.Error("不存在的任务应该返回 nil")
}
}
// ── 删除 ──────────────────────────────────────────────
func TestDeleteTasks_批量删除(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "222"})
n, err := DeleteTasks(db, []string{"COL-1"})
if err != nil {
t.Fatalf("删除失败: %v", err)
}
if n != 1 {
t.Fatalf("应该删除 1 条,实际 %d", n)
}
r, err := ListTasksView(db, repository.TaskFilter{})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-2" {
t.Fatalf("剩余任务应该只有 COL-2,实际 %+v", r.Rows)
}
}
// ── 目标列拼接的边界情况 ──────────────────────────────
func TestSpecText_空或无法解析时返回占位符(t *testing.T) {
if got := specText(""); got != placeholder {
t.Errorf("空 options: got %q", got)
}
if got := specText("not json"); got != placeholder {
t.Errorf("无法解析: got %q", got)
}
}
func TestQuantityText_零或负数返回占位符(t *testing.T) {
if got := quantityText(0); got != placeholder+"件" {
t.Errorf("数量为 0: got %q", got)
}
if got := quantityText(3); got != "3件" {
t.Errorf("数量为 3: got %q", got)
}
}
func TestPriceLimitText_零或负数返回占位符(t *testing.T) {
if got := priceLimitText(0); got != "≤"+placeholder {
t.Errorf("价格为 0: got %q", got)
}
if got := priceLimitText(4200); got != "≤¥42.00" {
t.Errorf("价格为 4200 分: got %q", got)
}
}