Files
cmautobuy/admin/service/task.go
T

663 lines
21 KiB
Go
Raw Normal View History

// 「采集采购」模块的业务逻辑(#19)。
//
// `tasks` 是一张表,用 task_type 区分采集和采购。这一层的核心工作是把
// 两种业务字段完全不同的任务,统一翻成界面能直接显示的东西——尤其是
// 「目标」这一列:拼接逻辑必须放在这里,模板只负责显示,
// 散在模板里的话谁都维护不了。
//
// 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。
package service
import (
"database/sql"
"encoding/json"
"fmt"
"net/url"
"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 是「分配客户端」列/字段的统一显示规则。
//
// `[必须]` 无主任务显示 `—`,不是空白也不是 `<nil>`——#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 <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
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 {
2026-08-09 21:51:20 +08:00
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。
2026-08-09 21:51:20 +08:00
func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) {
counts, err := repository.CountTasksByStatus(db, filter)
if err != nil {
return nil, err
}
2026-08-09 21:51:20 +08:00
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,
2026-08-09 21:51:20 +08:00
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]))
}
2026-08-09 21:51:20 +08:00
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
ShopeeOrderNo string
SybID string
ShopeeGoodsID string
PddGoodsID string
HasSybSource bool
ViewSybURL string
CanRecreatePurchase bool
RecreatePurchaseURL string
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.ShopeeOrderNo = orPlaceholder(t.OrderNo)
v.SybID = orPlaceholder(t.SybID)
v.ShopeeGoodsID = orPlaceholder(t.GoodsID)
v.PddGoodsID = orPlaceholder(t.PddGoodsID)
v.HasSybSource = strings.TrimSpace(t.SybID) != ""
if v.HasSybSource {
v.ViewSybURL = taskSybURL(*t, false)
v.CanRecreatePurchase = t.Status == model.TaskFailed || t.Status == model.TaskCancelled
if v.CanRecreatePurchase {
v.RecreatePurchaseURL = taskSybURL(*t, true)
}
}
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
}
// taskSybURL 生成采购任务来源的稳定深链接。syb_id 用于精确定位;订单号只负责
// 让跳转后的列表保留有意义的上下文,不能拿它代替明细主键。
func taskSybURL(task model.Task, purchaseIntent bool) string {
values := url.Values{"open_syb_id": {task.SybID}}
if strings.TrimSpace(task.OrderNo) != "" {
values.Set("order_no", task.OrderNo)
}
if purchaseIntent {
values.Set("purchase_intent", "1")
}
return "/syb?" + values.Encode()
}
// 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
}