Files
cmautobuy/admin/service/task.go
T

397 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「采集采购」模块的业务逻辑(#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 求和),不是全库总数
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) != "",
}
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 fmt.Sprintf("%s · 第 %d/%d 页", strings.Join(parts, " · "), r.Page, r.TotalPages)
}
// 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
}