Files
cmautobuy/admin/service/inner_code_apply.go
T

305 lines
11 KiB
Go

package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/syb"
)
// InnerCodeWriter 是安全回写所需的最小顺运宝接口。
type InnerCodeWriter interface {
InnerCodeDetailReader
DeleteInnerCode(context.Context, int64) error
UpdateDetailCode(context.Context, int64, int64, string) error
}
// InnerCodeApplyResult 是一次用户确认操作的统计。
type InnerCodeApplyResult struct {
Requested int
Updated int
AlreadyFilled int
Skipped int
Failed int
NeedsCheck int
}
// InnerCodeApplyBatch 是一次已经落库的后台回写请求。
type InnerCodeApplyBatch struct {
ID string
Count int
}
const innerCodeApplyChunkSize = 20
type innerCodeApplyOutcome struct {
Status model.InnerCodeStatus
Message string
RemoteCode string
}
// QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。
func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) {
ids = uniquePositiveInnerCodeIDs(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("没有选择可回写记录")
}
batchID, err := newInnerCodeApplyBatchID()
if err != nil {
return nil, err
}
count, err := repository.QueueInnerCodeApplyBatch(db, ids, batchID, actorUserID, model.NowISO())
if err != nil {
if errors.Is(err, repository.ErrInnerCodeApplyConflict) {
return nil, fmt.Errorf("所选记录中有记录已不再可回写;本批没有部分入队,请刷新后重新选择")
}
return nil, err
}
return &InnerCodeApplyBatch{ID: batchID, Count: count}, nil
}
// RunInnerCodeApplyBatch 分组读取后台队列,再逐条执行原有安全回写门禁。
// 分组大小只用于控制一次数据库读取,远端请求始终逐条发送且不会自动重试。
func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWriter, batchID, actorUserID string) (*InnerCodeApplyResult, error) {
result := &InnerCodeApplyResult{}
for {
if err := ctx.Err(); err != nil {
return result, err
}
ids, err := repository.ListQueuedInnerCodeIDs(db, batchID, innerCodeApplyChunkSize)
if err != nil {
return result, err
}
if len(ids) == 0 {
return result, nil
}
result.Requested += len(ids)
for _, id := range ids {
if err := ctx.Err(); err != nil {
return result, err
}
now := model.NowISO()
record, claimed, err := repository.ClaimQueuedInnerCodeForApply(db, id, batchID, actorUserID, now)
if err != nil {
return result, err
}
if !claimed {
result.Skipped++
continue
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return result, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
}
}
// InterruptInnerCodeApplyBatch 在后台异常时收敛当前批次,不自动重试任何远端请求。
func InterruptInnerCodeApplyBatch(db *sql.DB, batchID string) (int, int, error) {
return repository.InterruptInnerCodeApplyBatch(db, strings.TrimSpace(batchID), model.NowISO())
}
// GetInnerCodeApplyBatchProgress 返回页面使用的单表聚合进度。
func GetInnerCodeApplyBatchProgress(db *sql.DB, batchID string) (*repository.InnerCodeApplyBatchProgress, error) {
batchID = strings.TrimSpace(batchID)
if batchID == "" || len(batchID) > 191 {
return nil, nil
}
return repository.GetInnerCodeApplyBatchProgress(db, batchID)
}
func newInnerCodeApplyBatchID() (string, error) {
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("生成档口入库码后台批次失败: %w", err)
}
return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
item, err := readCurrentInnerCodeDetail(ctx, writer, record)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
}
currentCode := innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *item) {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode}
}
platform := innerCodeRawText(item.Raw["purchasePlatform"])
purchaseCode := innerCodeRawText(item.Raw["purchaseCode"])
if platform != "" || purchaseCode != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode}
}
if currentCode == record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled,
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode}
}
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
}
if currentCode != "" {
if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil {
status := model.InnerCodeFailed
message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
}
currentCode = ""
}
if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil {
status := model.InnerCodeFailed
message := "写入档口入库码失败,系统不会自动重试:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = "写入结果未知,禁止自动重试,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
}
verified, err := readCurrentInnerCodeDetail(ctx, writer, record)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode}
}
verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *verified) {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode}
}
if verifiedCode != record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode}
}
return innerCodeApplyOutcome{Status: model.InnerCodeUpdated,
Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode}
}
func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) {
if record.StockID <= 0 || record.DetailID <= 0 {
return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
}
stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID})
if err != nil {
return nil, err
}
if len(stocks) != 1 || stocks[0].ID != record.StockID {
return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID)
}
var found *syb.DetailItem
for index := range stocks[0].Details {
if stocks[0].Details[index].ID != record.DetailID {
continue
}
if found != nil {
return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID)
}
item := stocks[0].Details[index]
found = &item
}
if found == nil {
return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID)
}
return found, nil
}
// RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。
func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, id int64) (model.InnerCodeStatus, string, error) {
record, err := repository.GetInnerCodeForRecheck(db, id)
if err != nil {
return "", "", err
}
if record == nil {
return "", "", fmt.Errorf("档口入库码记录不存在")
}
if record.Status != model.InnerCodeNeedsCheck {
return record.Status, "当前记录不需要核对", nil
}
item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record)
status := model.InnerCodeNeedsCheck
remoteCode := record.RemoteInnerCode
message := "重新读取失败,仍需人工核对:" + errorText(readErr)
if readErr == nil {
remoteCode = innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(*record, *item) {
message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入"
} else if remoteCode == record.InnerCode {
status = model.InnerCodeUpdated
message = "重新读取确认远端已是目标入库码;没有重复写入"
} else {
message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入"
}
}
checkedAt := model.NowISO()
message = compactInnerCodeMessage(message)
if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil {
return "", "", err
}
return status, message, nil
}
func innerCodeDetailIdentityMatches(record model.InnerCodeRecord, item syb.DetailItem) bool {
return item.ProductSpec == record.SybSpec &&
innerCodeRawText(item.Raw["sku"]) == record.SybSKU &&
innerCodeRawText(item.Raw["variationSku"]) == record.SybVariationSKU
}
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func uniquePositiveInnerCodeIDs(ids []int64) []int64 {
seen := make(map[int64]bool, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if id > 0 && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
// InterruptApplyingInnerCodes 在 Admin 启动时收敛未确认的远端写结果。
func InterruptApplyingInnerCodes(db *sql.DB, now time.Time) (int, error) {
return repository.InterruptApplyingInnerCodes(db, now.UTC().Format(model.TimeLayout))
}
func compactInnerCodeMessage(message string) string {
message = strings.TrimSpace(message)
if len([]rune(message)) <= 500 {
return message
}
return string([]rune(message)[:500])
}