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:
@@ -263,6 +263,164 @@ func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCod
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 采集采购列表(#19) ----------
|
||||
|
||||
// TaskFilter 是「采集采购」列表页支持的筛选条件,三项都可以为空。
|
||||
type TaskFilter struct {
|
||||
Type model.TaskType
|
||||
Status model.TaskStatus
|
||||
Keyword string // 同时匹配任务编号、订单号、PDD 商品 ID
|
||||
}
|
||||
|
||||
// TaskListRow 是列表一行要用到的原始字段,还没翻成界面文字——
|
||||
// 那是 service 层的事(尤其是「目标」列的拼接,见 #19)。
|
||||
type TaskListRow struct {
|
||||
TaskID string
|
||||
TaskType model.TaskType
|
||||
Status model.TaskStatus
|
||||
AssignedClient string // 空表示无主任务
|
||||
|
||||
OrderNo string
|
||||
PddGoodsID string
|
||||
PddOptions string
|
||||
Quantity int
|
||||
MaxPriceCent int64
|
||||
|
||||
UpdatedAt string
|
||||
|
||||
// PddTitle 是 join pdd_products 拿到的标题,**可能为空**:
|
||||
// 没有对应商品、或商品已被软删除时都是空。
|
||||
// `[必须]` service 层退回显示 PddGoodsID,不能显示空,见 #19。
|
||||
PddTitle string
|
||||
}
|
||||
|
||||
// taskFilterClause 把三项筛选条件拼成 WHERE 子句,供 ListTasks 和
|
||||
// CountTasksByStatus 共用——两处筛选逻辑必须完全一致,
|
||||
// 否则底部统计会跟表格对不上(见 #19 的验收要求)。
|
||||
func taskFilterClause(filter TaskFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
|
||||
if filter.Type != "" {
|
||||
clauses = append(clauses, "t.task_type = ?")
|
||||
args = append(args, string(filter.Type))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
clauses = append(clauses, "t.status = ?")
|
||||
args = append(args, string(filter.Status))
|
||||
}
|
||||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||||
pattern := "%" + escapeLike(kw) + "%"
|
||||
clauses = append(clauses,
|
||||
"(t.task_id LIKE ? ESCAPE '\\' OR t.order_no LIKE ? ESCAPE '\\' OR t.pdd_goods_id LIKE ? ESCAPE '\\')")
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// ListTasks 查采集采购列表,按更新时间倒序。
|
||||
//
|
||||
// `[必须]` 目标列所需字段全在 tasks 表上,不需要 join。
|
||||
// `[建议]` 这里额外 LEFT JOIN 了 pdd_products 取标题,纯粹是为了让采集任务的
|
||||
// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和
|
||||
// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示
|
||||
// PddGoodsID 即可,不用在 service 里再判断一次已删除。
|
||||
func ListTasks(q Execer, filter TaskFilter) ([]TaskListRow, error) {
|
||||
where, args := taskFilterClause(filter)
|
||||
sqlText := `
|
||||
SELECT t.task_id, t.task_type, t.status, t.assigned_client,
|
||||
t.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent,
|
||||
t.updated_at, p.title
|
||||
FROM tasks t
|
||||
LEFT JOIN pdd_products p
|
||||
ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` +
|
||||
where + ` ORDER BY t.updated_at DESC, t.task_id DESC`
|
||||
|
||||
rows, err := q.Query(sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list := make([]TaskListRow, 0, 16)
|
||||
for rows.Next() {
|
||||
var r TaskListRow
|
||||
var assigned, orderNo, pddGoodsID, pddOptions, title sql.NullString
|
||||
var quantity, maxPrice sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.TaskID, &r.TaskType, &r.Status, &assigned,
|
||||
&orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice,
|
||||
&r.UpdatedAt, &title,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取任务列表失败: %w", err)
|
||||
}
|
||||
|
||||
r.AssignedClient = assigned.String
|
||||
r.OrderNo = orderNo.String
|
||||
r.PddGoodsID = pddGoodsID.String
|
||||
r.PddOptions = pddOptions.String
|
||||
r.Quantity = int(quantity.Int64)
|
||||
r.MaxPriceCent = maxPrice.Int64
|
||||
r.PddTitle = title.String
|
||||
list = append(list, r)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// CountTasksByStatus 按当前筛选统计各状态的任务数。
|
||||
//
|
||||
// `[必须]` **用和 ListTasks 完全相同的筛选条件**——统计要跟随当前筛选,
|
||||
// 筛了「采集」就只统计采集任务,否则底部数字和表格对不上,见 #19。
|
||||
func CountTasksByStatus(q Execer, filter TaskFilter) (map[model.TaskStatus]int, error) {
|
||||
where, args := taskFilterClause(filter)
|
||||
sqlText := `SELECT t.status, COUNT(*) FROM tasks t` + where + ` GROUP BY t.status`
|
||||
|
||||
rows, err := q.Query(sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("统计任务状态失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := map[model.TaskStatus]int{}
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[model.TaskStatus(status)] = n
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteTasks 按任务编号批量删除,返回实际删掉的条数。
|
||||
//
|
||||
// `[必须]` 硬删,不是软删除:tasks 表本来就没有 deleted_at 列,
|
||||
// 加一列属于数据库结构变更,不在本工单范围内(见 #19「不做」清单)。
|
||||
// 遗留下来的 task_claims 记录不删——它没有外键约束,留着不影响任何查询,
|
||||
// 之后想清理是可以独立做的小事,不值得为它扩大这次改动的范围。
|
||||
func DeleteTasks(q Execer, taskIDs []string) (int64, error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(taskIDs)), ",")
|
||||
args := make([]any, len(taskIDs))
|
||||
for i, id := range taskIDs {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
res, err := q.Exec(`DELETE FROM tasks WHERE task_id IN (`+placeholders+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("批量删除任务失败: %w", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// InsertCollectTask 建一条采集任务。
|
||||
//
|
||||
// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending,
|
||||
|
||||
Reference in New Issue
Block a user