Files
cmautobuy/admin/service/task.go
T

663 lines
21 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"
"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 {
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) != "" || 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]))
}
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
}