Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66952f640 | ||
|
|
3a2472dd20 |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: a0ad0d4ed6b71676b56276d84d713e85c6b206b8
|
||||
synchronized_at: 2026-09-19T03:52:01Z
|
||||
wiki_revision: 46067d78327476f14e6cfd202458a53f295bf763
|
||||
synchronized_at: 2026-09-19T07:18:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -498,3 +498,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
分支 fix/325-order-result,2026-09-19,尚未发布及真实下单验收。PurchaseLiveAutomation.readOrderResult 采用25次支付页返回后采样、首次待付款页独立30次采样/最多4次按需滑动;原过渡60次与总90次采样上限保留有界终止。parsePayableAmount只从本次只读标签集合提取明确应付总额。
|
||||
|
||||
数据链路:PurchaseOrderEvidence.pddOrderAmountCent → PurchaseExecutionOutcome → service/PurchaseResultPayload.kt → purchase.ResultRequest → lifecycle.go 的 order_created 分支 → 既有 purchase_task.pdd_order_amount_cent。重启只读核单也沿用同一可选字段。复用已有结果Outbox、幂等哈希和订单号唯一性;不新增表、配置、任务或SYB金额字段。未修改我的订单回填、Chrome扩展和Web,不能将旧入口的实付含义描述为本次已改成应付。
|
||||
|
||||
## Admin 订单资料补全与一致回填资格(#326)
|
||||
|
||||
实现3a2472d(2026-09-19),Server/Web,未合并发布;无迁移、无Android/插件改动。
|
||||
|
||||
- purchase/types.go ManualRequest 增加可选 pddOrderAmountCent;manual.go ResolveUnknown 复用既有行锁事务和模型单号保护,扩展为未知结果确认或已创建订单补缺。仅此操作启用新增重放内容校验和事务内 ensureOrderWriteback,其他人工付款/取消/授权流程不新增入队副作用。
|
||||
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
|
||||
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
|
||||
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 9320ce910ddb26881aafc4dfa4b497d62c144b5b
|
||||
synchronized_at: 2026-09-19T03:52:04Z
|
||||
wiki_revision: 975633525ff82f4be77b37b3d41f08aa05de6822
|
||||
synchronized_at: 2026-09-19T07:18:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -644,3 +644,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
|
||||
|
||||
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
|
||||
|
||||
## 采购订单资料完整性及简化单号回填(#326)
|
||||
|
||||
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
|
||||
|
||||
采购任务保存 PDD 订单号、下单时间和订单金额即为资料完整,不检查是否已付款;金额0元有效。复用 pddOrderNo/orderSubmittedAt/pddOrderAmountCent,金额以人民币分存储。order_created 不改义,仍是已创建订单事实;缺金额展示“订单信息待补全”,不能当作采购失败再次下单。页面统一称“订单金额”,其来源可能是#325应付或既有回填实付,不能将历史值统一称已付金额。
|
||||
|
||||
Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建且缺资料任务提供“补全订单信息”;已有字段只读、缺项可填。保存中禁用提交,失败保留输入;沿用既有权限,不增加审批。非执行中的未知结果人工确认与已有订单资料补全都会事务内保证单号回填记录,不修改支付复核或触发设备操作。
|
||||
|
||||
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
|
||||
|
||||
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: 17d42cfee87b84a306bfd991b24ca3f3948f1354
|
||||
synchronized_at: 2026-09-19T03:52:23Z
|
||||
wiki_revision: e43e83af5442005139b3234963afd317609b44ac
|
||||
synchronized_at: 2026-09-19T07:18:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1350,7 +1350,7 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
|
||||
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
|
||||
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + order_created + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录;网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录(#326 起不限制任务状态);网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
|
||||
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
|
||||
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
|
||||
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
|
||||
@@ -1377,3 +1377,17 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
|
||||
|
||||
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
|
||||
|
||||
## Admin 订单资料补全与字段型 SYB 入队(#326)
|
||||
|
||||
实现提交 3a2472d,2026-09-19,feat/326-order-information;隔离测试及构建通过,未合并 main、未发布、未执行真实 SYB 写入。
|
||||
|
||||
复用 POST /api/admin/v1/purchase-tasks/:id/resolve-unknown 及其原有管理员/采购员权限:live 的 order_result_unknown 可人工确认订单或取消;live 的 order_created 可补全订单信息,但不能通过此接口改成 cancelled。status=order_created 时沿用 pddOrderNo、orderSubmittedAt,增加可选非负整数分 pddOrderAmountCent,复用原数据库列。订单号非空、无首尾空白/换行且不超过100字符,时间须为有效非零时间;旧客户端省略金额仍接受并保留已有值。新 Admin 补录表单要求金额,0元合法,最多两位小数,转换为整数分后提交。
|
||||
|
||||
已保存非空字段只补缺,不覆盖不同单号、时间或金额;订单号唯一归属仍由原模型保护。相同 requestId/已保存内容重放不重复保存,改变处理结果或已保存字段返回原采购冲突错误。前端网络失败保留输入及相同载荷的 requestId;修改载荷使用新 requestId。复用原审计字段 UnknownResolveRequestID,无新增表或持久化状态。
|
||||
|
||||
资料完整性由订单号、有效下单时间和非空非负订单金额推导,页面显示“信息完整”或“订单信息待补全”。order_created 仍表示已创建订单,缺金额不转 failed,不代表已付款,不重新采购。
|
||||
|
||||
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
|
||||
|
||||
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
@@ -115,19 +116,31 @@ func (s *Service) Cancel(ctx context.Context, id uint64, req ManualRequest) (mod
|
||||
|
||||
func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) {
|
||||
return s.manual(ctx, id, req, func(t *models.PurchaseTask) error {
|
||||
if t.Status != models.PurchaseTaskStatusOrderResultUnknown {
|
||||
return fail(CodeStateConflict, "任务不是订单结果未知状态")
|
||||
if t.ExecutionMode != models.PurchaseExecutionModeLive || (t.Status != models.PurchaseTaskStatusOrderResultUnknown && t.Status != models.PurchaseTaskStatusOrderCreated) {
|
||||
return fail(CodeStateConflict, "仅正式采购的未知结果或已创建订单允许补录")
|
||||
}
|
||||
if t.Status == models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
return fail(CodeStateConflict, "已创建订单仅允许补全订单信息")
|
||||
}
|
||||
if req.Status != models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusCancelled {
|
||||
return fail(CodeInvalidRequest, "人工处理结果只支持已创建订单或已取消")
|
||||
}
|
||||
now := s.Now()
|
||||
if req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil {
|
||||
if strings.TrimSpace(req.PDDOrderNo) == "" || strings.TrimSpace(req.PDDOrderNo) != req.PDDOrderNo || utf8.RuneCountInString(req.PDDOrderNo) > 100 || strings.ContainsAny(req.PDDOrderNo, "\r\n\t") || req.OrderSubmittedAt == nil || req.OrderSubmittedAt.IsZero() || req.OrderSubmittedAt.Year() < 1000 || req.OrderSubmittedAt.Year() > 9999 {
|
||||
return fail(CodeInvalidRequest, "请填写订单号和下单时间")
|
||||
}
|
||||
if req.PDDOrderAmountCent != nil && *req.PDDOrderAmountCent < 0 {
|
||||
return fail(CodeInvalidRequest, "订单金额不能为负数")
|
||||
}
|
||||
if (t.PDDOrderNo != nil && *t.PDDOrderNo != "" && *t.PDDOrderNo != req.PDDOrderNo) || (t.OrderSubmittedAt != nil && !t.OrderSubmittedAt.Equal(*req.OrderSubmittedAt)) || (t.PDDOrderAmountCent != nil && req.PDDOrderAmountCent != nil && *t.PDDOrderAmountCent != *req.PDDOrderAmountCent) {
|
||||
return fail(CodeResultConflict, "已有不同订单信息,本次未覆盖")
|
||||
}
|
||||
t.PDDOrderNo = &req.PDDOrderNo
|
||||
t.OrderSubmittedAt = req.OrderSubmittedAt
|
||||
if req.PDDOrderAmountCent != nil {
|
||||
t.PDDOrderAmountCent = req.PDDOrderAmountCent
|
||||
}
|
||||
} else {
|
||||
t.CancelledAt = &now
|
||||
t.CancelledBy = &req.OperatorID
|
||||
@@ -140,12 +153,13 @@ func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualReque
|
||||
t.StatusVersion++
|
||||
t.StatusChangedAt = now
|
||||
return nil
|
||||
})
|
||||
}, true)
|
||||
}
|
||||
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error) (models.PurchaseTask, bool, error) {
|
||||
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error, orderInfo ...bool) (models.PurchaseTask, bool, error) {
|
||||
var out models.PurchaseTask
|
||||
replayed := false
|
||||
isOrderInfo := len(orderInfo) > 0 && orderInfo[0]
|
||||
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
|
||||
return out, false, fail(CodeInvalidRequest, "requestId 或操作人无效")
|
||||
}
|
||||
@@ -157,6 +171,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
return internal(e)
|
||||
}
|
||||
if requestAlreadyApplied(out, req.RequestID) {
|
||||
if isOrderInfo && out.Status != req.Status {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变处理结果")
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated && (out.PDDOrderNo == nil || *out.PDDOrderNo != req.PDDOrderNo || out.OrderSubmittedAt == nil || req.OrderSubmittedAt == nil || !out.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || (req.PDDOrderAmountCent != nil && (out.PDDOrderAmountCent == nil || *out.PDDOrderAmountCent != *req.PDDOrderAmountCent))) {
|
||||
return fail(CodeResultConflict, "同一requestId不能改变订单信息")
|
||||
}
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
@@ -172,6 +192,9 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
|
||||
if e := tx.Save(&out).Error; e != nil {
|
||||
return conflictOrInternal(e)
|
||||
}
|
||||
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated {
|
||||
return ensureOrderWriteback(tx, out)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, replayed, err
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOrderInformationManualCompletion(t *testing.T) {
|
||||
for _, amount := range []int64{0, 1300} {
|
||||
t.Run(taskNumber(uint64(amount)), func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-MANUAL", OrderSubmittedAt: task.IrreversibleAt}
|
||||
got, _, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || got.Status != models.PurchaseTaskStatusOrderCreated || got.PDDOrderAmountCent != nil {
|
||||
t.Fatalf("legacy save: %v", err)
|
||||
}
|
||||
row := loadOrderWriteback(t, db, task.ID)
|
||||
if row.Status != "pending" {
|
||||
t.Fatal(row.Status)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
req.PDDOrderAmountCent = &amount
|
||||
got, replay, err := s.ResolveUnknown(context.Background(), task.ID, req)
|
||||
if err != nil || replay || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
if got.PaymentReviewStatus != task.PaymentReviewStatus {
|
||||
t.Fatal("payment status changed")
|
||||
}
|
||||
if _, replay, err = s.ResolveUnknown(context.Background(), task.ID, req); err != nil || !replay {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusCancelled
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay status: %v", err)
|
||||
}
|
||||
req.Status = models.PurchaseTaskStatusOrderCreated
|
||||
changed := amount + 1
|
||||
req.PDDOrderAmountCent = &changed
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("changed replay: %v", err)
|
||||
}
|
||||
req.RequestID = uuid.NewString()
|
||||
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("duplicate queue: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationQueueFailureRollsBack(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("326_queue_failure", func(tx *gorm.DB) {
|
||||
if _, ok := tx.Statement.Dest.(*models.PurchaseOrderWriteback); ok {
|
||||
tx.AddError(errors.New("synthetic queue failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Callback().Create().Remove("326_queue_failure")
|
||||
amount := int64(100)
|
||||
_, _, err := s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-ROLLBACK", OrderSubmittedAt: task.IrreversibleAt, PDDOrderAmountCent: &amount})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
got := loadBackfillTask(t, db, task.ID)
|
||||
if got.PDDOrderNo != nil || got.PDDOrderAmountCent != nil || got.Status != task.Status {
|
||||
t.Fatal("partial transaction saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderWritebackFieldBasedEligibility(t *testing.T) {
|
||||
for _, status := range []string{models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusOrderResultUnknown} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
s, task := orderWritebackFixture(t)
|
||||
task.Status = status
|
||||
task.OrderSubmittedAt = nil
|
||||
task.PDDOrderAmountCent = nil
|
||||
if err := s.DB.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Existing queued items must also pass the same relaxed worker check.
|
||||
fake := &fakeOrderNumberClient{apply: true}
|
||||
if _, err := wbWorker(s, fake).RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.writes != 1 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
||||
t.Fatal("status/time/amount incorrectly gated worker")
|
||||
}
|
||||
// A failed writeback is selectable for manual compensation.
|
||||
s.DB.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Update("status", "failed")
|
||||
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || !views[task.ID].CanSubmit {
|
||||
t.Fatalf("view: %v", err)
|
||||
}
|
||||
result, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "pending" {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
s.DB.Model(&models.SYBProduct{}).Where("id = ?", *task.SYBProductID).Update("detail_id", 0)
|
||||
views, err = s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
||||
if err != nil || views[task.ID].CanSubmit {
|
||||
t.Fatal("invalid target selectable")
|
||||
}
|
||||
result, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
||||
if err != nil || result.Items[0].Result != "skipped" {
|
||||
t.Fatal("invalid target accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderInformationRejectInvalidInputs(t *testing.T) {
|
||||
for _, name := range []string{"negative", "zero_time", "blank_order", "running", "rehearsal"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
||||
amount := int64(100)
|
||||
stamp := *task.IrreversibleAt
|
||||
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-INVALID", OrderSubmittedAt: &stamp, PDDOrderAmountCent: &amount}
|
||||
switch name {
|
||||
case "negative":
|
||||
amount = -1
|
||||
case "zero_time":
|
||||
stamp = time.Time{}
|
||||
case "blank_order":
|
||||
req.PDDOrderNo = " "
|
||||
case "running":
|
||||
task.Status = models.PurchaseTaskStatusRunning
|
||||
case "rehearsal":
|
||||
task.ExecutionMode = models.PurchaseExecutionModeRehearsal
|
||||
}
|
||||
if err := db.Save(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.ResolveUnknown(context.Background(), task.ID, req); err == nil {
|
||||
t.Fatal("invalid input accepted")
|
||||
}
|
||||
if loadBackfillTask(t, db, task.ID).PDDOrderNo != nil {
|
||||
t.Fatal("invalid input persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,11 @@ type OrderWritebackResponse struct {
|
||||
}
|
||||
|
||||
func orderWritebackEligible(t models.PurchaseTask) bool {
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
|
||||
}
|
||||
|
||||
func validOrderWritebackTarget(syb models.SYBProduct) bool {
|
||||
return syb.StockID > 0 && syb.DetailID > 0 && syb.StockID <= 1<<63-1 && syb.DetailID <= 1<<63-1
|
||||
}
|
||||
|
||||
// Called within the same transaction as the order fact. Never contacts SYB.
|
||||
@@ -52,10 +56,7 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.StockID <= 0 || syb.DetailID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
|
||||
if !validOrderWritebackTarget(syb) {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
@@ -65,11 +66,31 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
out := map[uint64]OrderWritebackView{}
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
var sybIDs []uint64
|
||||
for _, t := range tasks {
|
||||
if orderWritebackEligible(t) {
|
||||
sybIDs = append(sybIDs, *t.SYBProductID)
|
||||
}
|
||||
}
|
||||
validTargets := map[uint64]bool{}
|
||||
if len(sybIDs) > 0 {
|
||||
var targets []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Select("id", "stock_id", "detail_id").Where("id IN ?", sybIDs).Find(&targets).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
validTargets[target.ID] = validOrderWritebackTarget(target)
|
||||
}
|
||||
}
|
||||
for _, t := range tasks {
|
||||
ids = append(ids, t.ID)
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
|
||||
v := OrderWritebackView{Status: "not_applicable", Reason: "需要正式SYB采购任务、订单号及有效SYB明细"}
|
||||
if orderWritebackEligible(t) {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
if validTargets[*t.SYBProductID] {
|
||||
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
|
||||
} else {
|
||||
v.Reason = "SYB明细关联已失效"
|
||||
}
|
||||
}
|
||||
out[t.ID] = v
|
||||
}
|
||||
@@ -83,7 +104,9 @@ func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.Purcha
|
||||
for _, r := range rows {
|
||||
v := out[r.PurchaseTaskID]
|
||||
v.Status = r.Status
|
||||
v.Reason = r.ErrorMessage
|
||||
if r.ErrorMessage != "" || v.CanSubmit || r.Status == "succeeded" {
|
||||
v.Reason = r.ErrorMessage
|
||||
}
|
||||
v.CompletedAt = r.CompletedAt
|
||||
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
|
||||
out[r.PurchaseTaskID] = v
|
||||
@@ -141,6 +164,15 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
var target models.SYBProduct
|
||||
if err := tx.First(&target, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(target) {
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
a.Reason = "SYB明细关联已失效"
|
||||
out.Items = append(out.Items, a)
|
||||
continue
|
||||
}
|
||||
if err := ensureOrderWriteback(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
|
||||
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
|
||||
}
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(syb) || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
|
||||
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
|
||||
@@ -180,10 +180,11 @@ type SpecDecisionRequest struct {
|
||||
}
|
||||
|
||||
type ManualRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
RequestID string `json:"requestId"`
|
||||
OperatorID uint64 `json:"operatorId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PDDOrderNo string `json:"pddOrderNo,omitempty"`
|
||||
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
||||
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
|
||||
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
|
||||
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
|
||||
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div v-if="row.pddOrderNo" class="muted">{{ orderInformationComplete(row) ? '信息完整' : '订单信息待补全' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
|
||||
<el-table-column label="SYB 单号回填" min-width="170"><template #default="{ row }"><span>{{ orderWritebackLabel(row.orderWriteback?.status) }}</span><div class="muted">{{ row.orderWriteback?.reason || '' }}</div></template></el-table-column>
|
||||
<el-table-column label="创建时间" width="170"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div v-if="['manual_required','cancelled'].includes(detail.matching.status)" class="matching-actions"><el-button :loading="matchingAction.saving" @click="requeueMatching">重新尝试 AI</el-button><el-button v-if="detail.matching.status === 'manual_required'" type="primary" @click="openMatchingDialog">人工选择规格</el-button></div>
|
||||
</section>
|
||||
<section><h3>订单与人工处理</h3><div class="state-grid">
|
||||
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small><small>实付价格:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}</small></div>
|
||||
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '下单时间尚未获取' }}</small><small>订单金额:{{ detail.task.pddOrderAmountCent == null ? '尚未获取' : priceText(detail.task.pddOrderAmountCent, 'CNY') }}</small><small>{{ orderInformationComplete(detail.task) ? '信息完整' : '订单信息待补全;请先核对已有订单,不要重复采购。' }}</small></div>
|
||||
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
|
||||
<div class="state-card"><span>物流</span><strong>{{ logisticsLabel(detail.task.logisticsStatus) }}</strong><small>{{ detail.task.trackingNo || '尚未取得快递单号' }}</small></div>
|
||||
<div v-if="detail.task.taskType !== 'stock'" class="state-card"><span>SYB 物流回填</span><strong>{{ writebackLabel(detail.task.writebackStatus) }}</strong><small>{{ detail.task.writebackAt ? formatTime(detail.task.writebackAt) : '由独立回填流程处理' }}</small></div>
|
||||
@@ -75,6 +75,7 @@
|
||||
<el-button v-if="detail.task.status === 'order_created' && detail.task.paymentReviewStatus === 'pending'" type="success" @click="confirmPayment('paid')">标记已支付</el-button>
|
||||
<el-button v-if="canSelectWriteback" type="primary" plain @click="confirmWriteback">选为回填候选</el-button>
|
||||
<el-button v-if="detail.task.status === 'order_result_unknown'" type="warning" @click="openUnknownDialog">处理未知结果</el-button>
|
||||
<el-button v-if="detail.task.executionMode === 'live' && detail.task.status === 'order_created' && !orderInformationComplete(detail.task)" type="primary" plain @click="openUnknownDialog">补全订单信息</el-button>
|
||||
<el-button v-if="canAuthorizeRepurchase" type="warning" plain @click="openRepurchaseDialog">允许重新采购</el-button>
|
||||
<el-button v-if="canCancel" type="danger" plain @click="openCancelDialog">标记已取消</el-button>
|
||||
</div>
|
||||
@@ -127,13 +128,14 @@
|
||||
<template #footer><el-button @click="cancelDialog.open=false">返回</el-button><el-button type="danger" :disabled="!cancelDialog.reason.trim()" :loading="cancelDialog.saving" @click="submitCancel">确认已取消</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="unknown.open" title="处理订单结果未知" width="620px" :close-on-click-modal="false">
|
||||
<el-dialog v-model="unknown.open" :title="detail.task?.status === 'order_created' ? '补全订单信息' : '处理订单结果未知'" width="620px" :close-on-click-modal="false" :close-on-press-escape="!unknown.saving" :show-close="!unknown.saving">
|
||||
<el-alert title="请先人工核对 PDD 订单。未确认前不要重复采购,避免产生重复订单。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form label-position="top"><el-form-item label="核对结果"><el-radio-group v-model="unknown.status"><el-radio value="order_created">订单已创建</el-radio><el-radio value="cancelled">确认没有订单,标记已取消</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="unknown.status === 'order_created'"><el-form-item label="PDD 订单号"><el-input v-model="unknown.pddOrderNo" /></el-form-item><el-form-item label="下单时间"><el-date-picker v-model="unknown.orderSubmittedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择下单时间" style="width:100%" /></el-form-item></template>
|
||||
<el-form label-position="top" :disabled="unknown.saving"><el-form-item v-if="detail.task?.status === 'order_result_unknown'" label="核对结果"><el-radio-group v-model="unknown.status"><el-radio value="order_created">订单已创建</el-radio><el-radio value="cancelled">确认没有订单,标记已取消</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="unknown.status === 'order_created'"><el-form-item label="PDD 订单号" required><el-input v-model="unknown.pddOrderNo" :readonly="Boolean(detail.task?.pddOrderNo)" /></el-form-item><el-form-item label="下单时间" required><el-date-picker v-model="unknown.orderSubmittedAt" :disabled="Boolean(detail.task?.orderSubmittedAt)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择下单时间" style="width:100%" /></el-form-item><el-form-item label="订单金额(人民币元)" required :error="unknown.amountYuan && orderAmountCent(unknown.amountYuan) === null ? '请输入不小于0、最多两位小数的金额' : ''"><el-input v-model="unknown.amountYuan" :readonly="detail.task?.pddOrderAmountCent != null" placeholder="例如 13.00" inputmode="decimal" /></el-form-item><p class="muted">订单金额只存 Admin,不代表已付款;SYB 仅回填订单号。</p></template>
|
||||
<el-form-item v-else label="说明"><el-input v-model="unknown.reason" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="unknown.open=false">返回</el-button><el-button type="warning" :disabled="!canSubmitUnknown" :loading="unknown.saving" @click="submitUnknown">确认处理结果</el-button></template>
|
||||
<el-alert v-if="unknown.error" :title="unknown.error" type="error" :closable="false" show-icon />
|
||||
<template #footer><el-button :disabled="unknown.saving" @click="unknown.open=false">返回</el-button><el-button type="primary" :disabled="!canSubmitUnknown || unknown.saving" :loading="unknown.saving" @click="submitUnknown">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
@@ -165,7 +167,7 @@ export default {
|
||||
retryResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
repurchase: { open: false, saving: false, reason: '', confirmText: '' },
|
||||
cancelDialog: { open: false, saving: false, reason: '' },
|
||||
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' }
|
||||
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', amountYuan: '', reason: '', error: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -183,7 +185,7 @@ export default {
|
||||
canSelectWriteback() { const t = this.detail.task; return t && t.taskType !== 'stock' && t.status === 'order_created' && t.paymentReviewStatus === 'paid' && t.writebackStatus === 'not_selected' },
|
||||
canCancel() { const t = this.detail.task; return t && !['running', 'order_submit_started', 'cancelled'].includes(t.status) },
|
||||
canSubmitRepurchase() { return this.repurchase.reason.trim() && this.repurchase.confirmText === '允许重新采购' },
|
||||
canSubmitUnknown() { return this.unknown.status === 'order_created' ? this.unknown.pddOrderNo.trim() && this.unknown.orderSubmittedAt : this.unknown.reason.trim() },
|
||||
canSubmitUnknown() { return this.unknown.status === 'order_created' ? this.unknown.pddOrderNo.trim() && this.unknown.orderSubmittedAt && this.orderAmountCent(this.unknown.amountYuan) !== null : this.unknown.reason.trim() },
|
||||
canSubmitMatching() { const m = this.detail.matching || {}; return (!m.targetColor || this.matchingDialog.color) && (!m.targetSize || this.matchingDialog.size) },
|
||||
firstRetriedTask() { return this.retryResult.items.find(item => item.created && item.taskId) || null }
|
||||
},
|
||||
@@ -272,8 +274,20 @@ export default {
|
||||
async submitRepurchase() { this.repurchase.saving = true; try { await this.runAction(() => authorizeRepurchase(this.detail.task.id, { requestId: this.requestId(), reason: this.repurchase.reason.trim() }), '已授权一次重新采购'); this.repurchase.open = false } finally { this.repurchase.saving = false } },
|
||||
openCancelDialog() { this.cancelDialog = { open: true, saving: false, reason: '' } },
|
||||
async submitCancel() { this.cancelDialog.saving = true; try { await this.runAction(() => cancelPurchaseTask(this.detail.task.id, { requestId: this.requestId(), reason: this.cancelDialog.reason.trim() }), '已标记任务取消'); this.cancelDialog.open = false } finally { this.cancelDialog.saving = false } },
|
||||
openUnknownDialog() { this.unknown = { open: true, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' } },
|
||||
async submitUnknown() { const payload = { requestId: this.requestId(), status: this.unknown.status, pddOrderNo: this.unknown.pddOrderNo.trim() || undefined, orderSubmittedAt: this.unknown.orderSubmittedAt || undefined, reason: this.unknown.reason.trim() || undefined }; this.unknown.saving = true; try { await this.runAction(() => resolveUnknownPurchaseTask(this.detail.task.id, payload), '未知结果已处理'); this.unknown.open = false } finally { this.unknown.saving = false } },
|
||||
orderInformationComplete(t) { return Boolean(t?.pddOrderNo?.trim() && t.orderSubmittedAt && Number.isFinite(Date.parse(t.orderSubmittedAt)) && t.pddOrderAmountCent != null && t.pddOrderAmountCent >= 0) },
|
||||
orderAmountCent(value) { if (!/^\d+(\.\d{1,2})?$/.test(value)) return null; const [whole, fraction = ''] = value.split('.'); const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')); return Number.isSafeInteger(cents) ? cents : null },
|
||||
openUnknownDialog() { const t = this.detail.task; this.unknown = { open: true, saving: false, status: 'order_created', pddOrderNo: t.pddOrderNo || '', orderSubmittedAt: t.orderSubmittedAt || '', amountYuan: t.pddOrderAmountCent == null ? '' : (t.pddOrderAmountCent / 100).toFixed(2), reason: '', error: '', requestId: this.requestId(), fingerprint: '' } },
|
||||
async submitUnknown() {
|
||||
if (!this.canSubmitUnknown || this.unknown.saving) return
|
||||
const values = this.unknown.status === 'order_created' ? { status: 'order_created', pddOrderNo: this.unknown.pddOrderNo.trim(), orderSubmittedAt: this.unknown.orderSubmittedAt, pddOrderAmountCent: this.orderAmountCent(this.unknown.amountYuan) } : { status: 'cancelled', reason: this.unknown.reason.trim() }
|
||||
const fingerprint = JSON.stringify(values)
|
||||
if (this.unknown.fingerprint && this.unknown.fingerprint !== fingerprint) this.unknown.requestId = this.requestId()
|
||||
this.unknown.fingerprint = fingerprint
|
||||
this.unknown.saving = true; this.unknown.error = ''
|
||||
try { await this.runAction(() => resolveUnknownPurchaseTask(this.detail.task.id, { requestId: this.unknown.requestId, ...values }), '订单信息已保存'); this.unknown.open = false }
|
||||
catch (error) { this.unknown.error = '保存未确认,输入已保留。请核对订单号及现有信息;网络异常时刷新确认后重试。' }
|
||||
finally { this.unknown.saving = false }
|
||||
},
|
||||
openShopeeMapping() { const route = this.$router.resolve({ path: '/shopee-products/index', query: { productId: this.detail.task.shopeeProductId }}); window.open(route.href, '_blank', 'noopener') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
async function setup(page: any, state = 'not_started') {
|
||||
async function setup(page: any, state = 'not_started', failManualOnce = false) {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'test-only', domain: 'localhost', path: '/' }])
|
||||
const requests: { path: string, body: any }[] = []
|
||||
const items = [
|
||||
{ id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', pddOrderNo: 'EXAMPLE-11', paymentReviewStatus: 'unpaid', orderWriteback: { status: state, canSubmit: ['not_started', 'failed', 'unknown'].includes(state), reason: state === 'conflict' ? 'SYB 已有不同单号,未覆盖' : '' } },
|
||||
{ id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', pddOrderNo: 'EXAMPLE-11', orderSubmittedAt: '2026-09-19T12:00:00Z', pddOrderAmountCent: null as number | null, paymentReviewStatus: 'unpaid', orderWriteback: { status: state, canSubmit: ['not_started', 'failed', 'unknown'].includes(state), reason: state === 'conflict' ? 'SYB 已有不同单号,未覆盖' : '' } },
|
||||
{ id: 12, taskType: 'syb_order', executionMode: 'live', status: 'failed', retryable: true, orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } },
|
||||
{ id: 13, taskType: 'stock', executionMode: 'live', status: 'order_created', orderWriteback: { status: 'not_applicable', canSubmit: false, reason: '' } }
|
||||
]
|
||||
@@ -16,6 +16,11 @@ async function setup(page: any, state = 'not_started') {
|
||||
if (route.request().method() === 'POST') {
|
||||
const body = route.request().postDataJSON()
|
||||
requests.push({ path, body })
|
||||
if (path.endsWith('/resolve-unknown')) {
|
||||
if (failManualOnce) { failManualOnce = false; return route.fulfill({ status: 500, json: { code: 500, message: 'synthetic failure' } }) }
|
||||
items[0].pddOrderAmountCent = body.pddOrderAmountCent
|
||||
return route.fulfill({ json: { code: 200, data: items[0] } })
|
||||
}
|
||||
if (path.endsWith('/syb-order-writeback')) {
|
||||
items[0].orderWriteback = { status: 'pending', canSubmit: false, reason: '' }
|
||||
return route.fulfill({ json: { code: 200, data: { items: body.purchaseTaskIds.map((taskId: number) => ({ taskId, result: 'pending', reason: '已受理' })) } } })
|
||||
@@ -76,3 +81,43 @@ test('冲突显示原因且没有覆盖按钮', async ({ page }) => {
|
||||
await expect(drawer.getByText('SYB 已有不同单号,未覆盖', { exact: true })).toBeVisible()
|
||||
await expect(drawer.getByRole('button', { name: /回填 SYB 单号/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const amount of ['0', '13.01']) {
|
||||
test(`已创建订单补金额 ${amount},不重新采购或修改支付状态`, async ({ page }) => {
|
||||
const requests = await setup(page)
|
||||
await page.getByRole('button', { name: '详情', exact: true }).first().click()
|
||||
const drawer = page.getByRole('dialog', { name: '采购任务详情' })
|
||||
await drawer.getByRole('button', { name: '补全订单信息', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '补全订单信息', exact: true })
|
||||
await expect(dialog.getByRole('button', { name: '保存', exact: true })).toBeDisabled()
|
||||
await expect(dialog.getByRole('textbox').first()).toHaveAttribute('readonly', '')
|
||||
await dialog.getByPlaceholder('例如 13.00').fill('-1')
|
||||
await expect(dialog.getByText('请输入不小于0、最多两位小数的金额')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '保存', exact: true })).toBeDisabled()
|
||||
await dialog.getByPlaceholder('例如 13.00').fill(amount)
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].body.pddOrderAmountCent).toBe(Math.round(Number(amount) * 100))
|
||||
expect(requests[0].body.pddOrderNo).toBe('EXAMPLE-11')
|
||||
expect(requests[0].body.orderSubmittedAt).toBe('2026-09-19T12:00:00Z')
|
||||
expect(requests[0].path).toContain('/resolve-unknown')
|
||||
await expect(drawer.getByText('信息完整', { exact: true })).toBeVisible()
|
||||
await expect(drawer.getByRole('button', { name: '补全订单信息', exact: true })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('补录失败保留输入,原样重试复用requestId', async ({ page }) => {
|
||||
const requests = await setup(page, 'not_started', true)
|
||||
await page.getByRole('button', { name: '详情', exact: true }).first().click()
|
||||
await page.getByRole('button', { name: '补全订单信息', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '补全订单信息', exact: true })
|
||||
await dialog.getByPlaceholder('例如 13.00').fill('15.50')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(dialog.getByText(/保存未确认/)).toBeVisible()
|
||||
await expect(dialog.getByPlaceholder('例如 13.00')).toHaveValue('15.50')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1].body).toEqual(requests[0].body)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user