Files
cmautobuy/admin/service/service.go
T

226 lines
8.0 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.
// Package service 放业务逻辑。
//
// 本包**不认识 *gin.Context**——这样才能不起服务器就写单元测试。
// handler 负责取参数,service 负责判断和编排,repository 负责读写数据库。
//
// 骨架阶段这里只有函数签名和 TODO,实现按工单逐个补。
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ErrNotImplemented 表示该功能还没实现。
// 补完实现后要把对应的返回删掉,不要留着假装能用。
var ErrNotImplemented = errors.New("功能尚未实现")
// ---------- 蝦皮 Excel 导入 ----------
//
// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go,
// 那个文件还负责上传文件的安全校验(大小、扩展名、内容魔数)。
// ---------- 采集 ----------
// CreateCollectTasks 为若干蝦皮商品创建采集任务。
//
// 规则:
// - 按 goods_id **去重**(一个商品有多个 SKU 行,别建重复任务);
// - PDD 链接为空的跳过;
// - collect_status 已是 collecting 的跳过,并在结果里说明跳过了几个;
// - 建任务成功后把 collect_status 置为 collecting。
func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created, skipped int, err error) {
// TODO(骨架)
return 0, 0, ErrNotImplemented
}
// ---------- 规格匹配 ----------
// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。
//
// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。
// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error {
// TODO(骨架): upsert sku_mappings
return ErrNotImplemented
}
// ---------- 采购任务 ----------
// TaskCreateError 说明某一条为什么建不了任务。
// **不要静默跳过**,要把这些列出来告诉操作员缺什么。
type TaskCreateError struct {
SybID string
Reason string // 例如「该商品未填写 PDD 链接」
}
// CreatePurchaseTasks 由货运单创建采购任务。
//
// 六条校验缺一不可(docs/admin/01-requirements.md §5):
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出,
// 操作员可改但不允许为空。没有它 Client 会拒绝执行。
// 6. 已选择分配的客户端
//
// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。
func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) {
// TODO(骨架)
return 0, nil, ErrNotImplemented
}
// ---------- 客户端 ----------
// RegisterClient 登记或更新一台客户端。
//
// explicit 的含义见 repository.UpsertClient 的说明:
// true = 设置页点保存(会更新名称),false = claim 顺带(不更新名称)。
func RegisterClient(db repository.Execer, c model.Client, explicit bool) error {
return repository.UpsertClient(db, c, explicit)
}
// RegisterClientProfile 处理设置页发起的**显式登记**。
//
// `[必须]` 本函数**不读取、不领取、不修改任何任务**,也不返回任务。
// 这正是它存在的理由:设置页点"保存"不该顺带把一个任务领走——
// 领走了 Admin 就把任务标成 claimed 了,而保存动作没有义务
// 去可靠保存那个任务,任务就丢了。
func RegisterClientProfile(db *sql.DB, clientID string, p ClientProfileRequest) (string, error) {
if clientID == "" {
return "", fmt.Errorf("client_id 不能为空")
}
if err := p.Validate(); err != nil {
return "", err
}
if err := RegisterClient(db, p.ToClient(clientID), true); err != nil {
return "", err
}
return model.NowISO(), nil
}
// TouchClient 刷新 last_seen_at。
//
// claim / result / failure **三个接口都要调**。
// 只在 claim 里调的话,客户端执行长任务期间不调 claim,
// 会被误判成离线。
func TouchClient(db *sql.DB, clientID string) error {
return repository.TouchClient(db, clientID)
}
// ClientView 是客户端列表页要显示的一行。
// Status 是**算出来的**,数据库里没有这个字段。
type ClientView struct {
model.Client
Status string
AssignedUserID string
AssignedUsername string
}
// ListClientViews 查客户端列表,并把在线状态算出来。
func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]ClientView, error) {
return listClientViews(db, keyword, "", threshold)
}
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) {
if actor == nil {
return nil, ErrUnauthenticated
}
visibleUserID := ""
if !actor.IsAdmin() {
if actor.Role != model.RolePurchaser {
return nil, ErrAdminRequired
}
visibleUserID = actor.UserID
}
return listClientViews(db, keyword, visibleUserID, threshold)
}
func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) {
clients, err := repository.ListClientsForUser(db, keyword, visibleUserID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
views := make([]ClientView, 0, len(clients))
for _, c := range clients {
views = append(views, ClientView{
Client: c.Client,
Status: c.StatusText(now, threshold),
AssignedUserID: c.AssignedUserID,
AssignedUsername: c.AssignedUsername,
})
}
return views, nil
}
// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
return ListClientViewsForUser(db, actor, "", threshold)
}
// ListActivePurchasers 返回管理员可选择的绑定目标。
func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) {
if actor == nil || !actor.IsAdmin() {
return nil, ErrAdminRequired
}
return repository.ListActivePurchasers(db)
}
// AssignClient 由管理员完成首次绑定或转交。
func AssignClient(db *sql.DB, actor *model.User, clientID, purchaserUserID string, now time.Time) (bool, bool, error) {
if actor == nil || !actor.IsAdmin() {
return false, false, ErrAdminRequired
}
clientID = strings.TrimSpace(clientID)
purchaserUserID = strings.TrimSpace(purchaserUserID)
if clientID == "" || purchaserUserID == "" {
return false, false, invalidInput("客户端和采购员都不能为空")
}
id, err := randomID("CA-", 16)
if err != nil {
return false, false, err
}
at := now.UTC().Format(model.TimeLayout)
return repository.AssignClient(db, model.ClientUserAssignment{
AssignmentID: id, ClientID: clientID, UserID: purchaserUserID,
StartedAt: at, AssignedByUserID: actor.UserID,
})
}
// UnassignClient 由管理员结束当前归属,不改动任何任务。
func UnassignClient(db *sql.DB, actor *model.User, clientID string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return invalidInput("客户端不能为空")
}
return repository.UnassignClient(db, clientID, actor.UserID, now.UTC().Format(model.TimeLayout))
}
// DeleteClients 批量删除,返回实际删除条数。
func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
return repository.DeleteClients(db, clientIDs)
}
// ---------- 领取任务 ----------
// ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。
//
// 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes)
}