Files
affiliate_dash/backend/internal/service/rock_recover.go
T
yml2213 e293cebaf5 feat(rock): 发货记录表格新增时间与备注列并支持上报失效联动
- 做了什么:
  1. 数据库新增 013_rock_tokens_remark.sql 迁移,rock_tokens 表补充 remark 字段并在 ListTokens 中支持备注模糊检索;
  2. 后端扩展 UpdateTokenRemark、MarkTokenAbnormal 接口,同时优化 ResetMismatchToken 支持 mismatch 与 abnormal 状态的解冻重置;
  3. 前端 RockTokenTable 新增「流转时间」列(入库/绑错/发货/绑定/提货)与「备注」列(TokenRemarkCell 浮窗快捷预设选择与自定义输入);
  4. 快捷选择「已上报」或点击「上报失效」自动将链接标记为 abnormal 作废,从可用库存池中剔除,防止被新订单领走;
  5. 优化操作列与顶部筛选栏:mismatch 增加「上报失效」与警示重置;abnormal 状态明确显示已失效并提供强制恢复;筛选栏新增「已上报失效」Tab。

- 为什么:
  解决绑定错误上报 0868 上游后无法记录备注、且原「解冻重置」容易误将已作废卡密放回可用库存池导致后续订单报错的问题。

- 验证方式:
  1. 后端单元测试 TestRockService_TokenRemarkAndAbnormal 与 TestRock 测试集通过;
  2. make fmt、go vet 通过;
  3. 前端 oxlint 检查通过 (0 errors, 0 warnings),npm run build 生产构建通过。
2026-10-08 20:13:25 +08:00

176 lines
5.6 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CorrectMismatchExpectedUID 客服修改买家填写的 UID,提货号继续留在原订单上。
// 改成当前扫码角色的 UID 时解除阻断并记为已绑定;改成其他 UID 只更新期望值,仍保持阻断。
func (s *RockService) CorrectMismatchExpectedUID(ctx context.Context, tokenID uint, expectedUID string) (*model.RockToken, error) {
trimmedUID := strings.TrimSpace(expectedUID)
if trimmedUID == "" || !isDigits(trimmedUID) || len(trimmedUID) > 15 {
return nil, errors.New("请填写 15 位以内的纯数字 UID")
}
var token model.RockToken
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("提货号不存在")
}
return err
}
if token.Status != model.RockStatusMismatch {
return errors.New("仅绑定错误的提货号可以修改买家 UID")
}
if strings.TrimSpace(token.AssignedOrderNo) == "" {
return errors.New("该提货号没有关联订单,不能修改 UID")
}
now := time.Now()
actualUID := strings.TrimSpace(token.ActualRoleID)
updates := map[string]interface{}{
"expected_uid": trimmedUID,
"updated_at": now,
}
if actualUID != "" && trimmedUID == actualUID {
updates["status"] = model.RockStatusBound
updates["mismatch_reason"] = ""
updates["mismatch_at"] = nil
updates["bound_at"] = now
token.Status = model.RockStatusBound
token.MismatchReason = ""
token.MismatchAt = nil
token.BoundAt = &now
} else if actualUID != "" {
updates["mismatch_reason"] = fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedUID, token.ActualRoleName, actualUID)
token.MismatchReason = updates["mismatch_reason"].(string)
}
if err := tx.Model(&token).Updates(updates).Error; err != nil {
return err
}
token.ExpectedUID = trimmedUID
var order model.FulfillmentOrder
if err := tx.Where("order_no = ?", token.AssignedOrderNo).First(&order).Error; err == nil {
data := resultDataMap(order.ResultData)
data["expected_uid"] = trimmedUID
raw, marshalErr := json.Marshal(data)
if marshalErr != nil {
return marshalErr
}
if err := tx.Model(&order).Update("result_data", string(raw)).Error; err != nil {
return err
}
}
return writeAudit(tx, nil, nil, nil, "rock_token.correct_expected_uid", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
"assigned_order_no": token.AssignedOrderNo,
"expected_uid": trimmedUID,
"actual_role_id": actualUID,
"status": token.Status,
})
})
if err != nil {
return nil, err
}
return &token, nil
}
func isDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
// ResetMismatchToken 客服人工重置解冻绑定错误或作废的 Token
func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return err
}
if token.Status != model.RockStatusMismatch && token.Status != model.RockStatusAbnormal {
return fmt.Errorf("仅 mismatch 或 abnormal 状态的 Token 允许人工解冻重置")
}
s.refreshCache.Delete(token.UpstreamTokenID)
// 重置回 available,清空订单关联与异常标记
return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"updated_at": time.Now(),
}).Error
}
// UpdateTokenRemark 更新 Token 备注,并支持联动流转状态(例如上报后置为 abnormal 失效)
func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remark string, nextStatus string) (*model.RockToken, error) {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, err
}
updates := map[string]interface{}{
"remark": strings.TrimSpace(remark),
"updated_at": time.Now(),
}
trimmedStatus := strings.TrimSpace(nextStatus)
if trimmedStatus != "" {
switch trimmedStatus {
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
updates["status"] = trimmedStatus
token.Status = trimmedStatus
if trimmedStatus == model.RockStatusAbnormal {
s.refreshCache.Delete(token.UpstreamTokenID)
}
default:
return nil, fmt.Errorf("不支持的目标状态: %s", trimmedStatus)
}
}
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
return nil, err
}
token.Remark = updates["remark"].(string)
_ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
"remark": token.Remark,
"status": token.Status,
})
return &token, nil
}
// MarkTokenAbnormal 快捷标记为上报失效(算作废,不可再用)
func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reason string) (*model.RockToken, error) {
remark := strings.TrimSpace(reason)
if remark == "" {
remark = "已上报上游 (失效作废)"
}
return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal)
}