392 lines
12 KiB
Go
392 lines
12 KiB
Go
// 「采集采购」模块的业务逻辑(#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(¢)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 列表 ----------
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
}
|