244 lines
8.8 KiB
Go
244 lines
8.8 KiB
Go
package service
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"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
|
||
|
|
}
|
||
|
|
|
||
|
|
type innerCodeApplyOutcome struct {
|
||
|
|
Status model.InnerCodeStatus
|
||
|
|
Message string
|
||
|
|
RemoteCode string
|
||
|
|
}
|
||
|
|
|
||
|
|
// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。
|
||
|
|
func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) {
|
||
|
|
ids = uniquePositiveInnerCodeIDs(ids)
|
||
|
|
if len(ids) == 0 {
|
||
|
|
return nil, fmt.Errorf("没有选择可回写记录")
|
||
|
|
}
|
||
|
|
if len(ids) > PageSize {
|
||
|
|
return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize)
|
||
|
|
}
|
||
|
|
result := &InnerCodeApplyResult{Requested: len(ids)}
|
||
|
|
for _, id := range ids {
|
||
|
|
now := model.NowISO()
|
||
|
|
record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 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 nil, 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++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, 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])
|
||
|
|
}
|