Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d331bee9 | ||
|
|
897c1817d2 | ||
|
|
5729ba048f | ||
|
|
9c5e20e249 | ||
|
|
dd9160ec42 | ||
|
|
14231c62a7 | ||
|
|
4280115e11 | ||
|
|
a4c7b5e97b | ||
|
|
e7c049dbd6 | ||
|
|
3b95d4e879 | ||
|
|
bce4897390 | ||
|
|
c13dcd3ebc | ||
|
|
0b01b92c98 | ||
|
|
9dace6ac89 | ||
|
|
e99bbc2327 | ||
|
|
abdf90775c | ||
|
|
934a7beabc | ||
|
|
24100f04d4 | ||
|
|
266418ac47 | ||
|
|
5e5fdd797e | ||
|
|
58c8c077ad | ||
|
|
9f679f6403 | ||
|
|
977ac235e2 | ||
|
|
573f5b8cf3 | ||
|
|
1491a9677a | ||
|
|
d4d32c6e89 | ||
|
|
b7038633f0 | ||
|
|
844f611d77 | ||
|
|
d415971268 | ||
|
|
6fe588d5e3 | ||
|
|
17cbfaec85 |
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 1e87b8832cecd454e58f0ff49dbe05bbf8d61544
|
||||
synchronized_at: 2026-09-29T03:47:39Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
|
||||
synchronized_at: 2026-09-27T02:47:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
@@ -520,3 +536,45 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||
|
||||
## GoAuto 售后角色与退货模块权限(#341)
|
||||
|
||||
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
|
||||
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
|
||||
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
|
||||
|
||||
|
||||
## #338 退货匹配(2026-09-27)
|
||||
|
||||
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||
|
||||
|
||||
## Yeeke 已重出同步与匹配边界(#345)
|
||||
|
||||
- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。
|
||||
- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。
|
||||
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
|
||||
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
|
||||
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
|
||||
## SYB 批量取消退货匹配(#347)
|
||||
|
||||
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
|
||||
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
|
||||
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。
|
||||
|
||||
- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。
|
||||
- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。
|
||||
- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。
|
||||
- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。
|
||||
|
||||
## 档口入库码匹配容错与手动批量重匹配(#350)
|
||||
- `sybinnercode/match.go` 在 SKU/档口证据筛选与分配结果为空时明确受限,不访问空数组;只读单条规划通过 `safePlanRecord` 隔离异常,下一条继续处理,不调用远端写接口。
|
||||
- `failMatchJob` 用独立 10 秒清理上下文收敛中断批次及其 pending/matching 记录;成功计划和回写记录保留。`cmd/api/server.go` 在服务启动时调用 `RecoverInterruptedMatches`,不自动重跑匹配或写入。
|
||||
- rematch 在原行锁事务内检查资格和活动批次占用,排队记录改为 matching,阻止重叠请求;matching 记录不可删除。
|
||||
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: c483a1f3fff3144dfad1ae24e6ca0b1de643ac12
|
||||
synchronized_at: 2026-09-29T06:14:25Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
|
||||
synchronized_at: 2026-09-27T02:47:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
@@ -319,11 +335,11 @@ synchronized_at: 2026-09-22T02:56:27Z
|
||||
## SYB 档口入库码导入、匹配与回写
|
||||
|
||||
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,不提供独立“匹配”按钮。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
|
||||
- 回写前弹窗展示业务记录数、入库码总数、预计占位明细数和替换旧码数。只有 `ready` 记录可提交;所有服务实例共用数据库租约全局串行执行。
|
||||
- 每个远端写动作只发送一次,动作前重读整张货运单并校验匹配计划未漂移;每件写入后重读确认目标码唯一位于预期明细。超时、5xx、响应无法确认或服务在写入期间重启时转为 `needs_check`,禁止自动重试;“只读复核”只能读取远端状态。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `matching`、`queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
|
||||
|
||||
## PDD 失效或售罄商品替换(#129)
|
||||
|
||||
@@ -680,3 +696,51 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
## 管理端售后角色(#341)
|
||||
|
||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
|
||||
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
|
||||
|
||||
|
||||
## 退货匹配(#338)
|
||||
|
||||
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||
|
||||
|
||||
## Yeeke 双状态退货同步(#345)
|
||||
|
||||
- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。
|
||||
- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。
|
||||
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
|
||||
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
|
||||
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
|
||||
## SYB 当前页批量取消匹配(#347)
|
||||
|
||||
- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。
|
||||
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
|
||||
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
|
||||
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
|
||||
|
||||
## SYB 当前页批量使用退货(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。
|
||||
|
||||
- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。
|
||||
- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。
|
||||
- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。
|
||||
- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。
|
||||
- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。
|
||||
|
||||
## 档口入库码手动匹配与中断恢复(#350)
|
||||
- 页面首次进入默认本地今天~今天;清除筛选明确取消日期限制。可勾选当前页 pending/failed/skipped 记录点击“匹配 N”;混选只提交合格项,跨营业日期提示分日期处理。加载/列表失败/提交/活动匹配期间禁用匹配。
|
||||
- 手动匹配仍按原精确订单、规格、SKU/档口和数量证据,不可强行选近似商品。空候选明确 skipped;单条规划异常 failed 并继续后续记录;匹配成功仅 ready,不自动回写。
|
||||
- 原 rematch 可接受同日 pending/failed/skipped;已有活动匹配任务占用或其他状态均拒绝;同 requestId 幂等重放,同记录排队为 matching。matching 期间不可删除,成功/回写/需复核记录不能重新匹配。
|
||||
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
|
||||
### 档口入库码排版字符兼容(#350 v2)
|
||||
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
|
||||
|
||||
@@ -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: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:15:05Z
|
||||
wiki_revision: da26af9e01087c4970b01783a4807c689fd79d51
|
||||
synchronized_at: 2026-09-29T03:47:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1399,3 +1399,29 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
|
||||
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
|
||||
|
||||
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
|
||||
## 管理端退货匹配取消前提(#347)
|
||||
|
||||
此为 Admin 共享管理接口补充,不改变 Android Agent 契约。
|
||||
|
||||
`POST /api/admin/v1/return-matches/:id/cancel` 可携带 `{"expectedStatus":"matched"}`。仅允许此值;其他值(含空字符串、null)或错误 JSON 返回 HTTP 400 / `INVALID_REQUEST`。省略请求体或属性兼容旧单条取消。
|
||||
|
||||
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
|
||||
|
||||
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
|
||||
|
||||
## Admin 批量使用退货复用确认接口(#349)
|
||||
|
||||
实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。
|
||||
|
||||
- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。
|
||||
- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。
|
||||
- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。
|
||||
- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。
|
||||
|
||||
## Admin 档口入库码 rematch 补充(#350,Agent 接口不变)
|
||||
- `POST /api/admin/v1/syb-inner-codes/rematch` 保留 `{requestId, ids}` 与响应 `{matchJobId, queued}`;原权限与客户端密钥 match 功能权限不变,不新增权限项或迁移。
|
||||
- 同日 pending/failed/skipped 可以排队;matching、ready、updated、already_filled、queued、applying、needs_check 和活动匹配占用拒绝 409。排队事务记录 matching,避免新 requestId 重叠提交;同 requestId 返回原结果。
|
||||
- 任务正常结束沿用 succeeded + ready/failed 分项计数;批次中断/服务启动恢复为 failed 并提供安全说明与 finishedAt,未完成记录 failed 可重新匹配,已完成结果不回滚。匹配仅只读 SYB,成功需另行 apply 确认。
|
||||
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
@@ -525,6 +525,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
|
||||
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
|
||||
|
||||
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
|
||||
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
|
||||
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
|
||||
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
|
||||
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
|
||||
`not_selected`。
|
||||
|
||||
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
|
||||
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
|
||||
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
|
||||
synchronized_at: 2026-09-29T03:47:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
|
||||
synchronized_at: 2026-09-27T02:47:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
@@ -10,13 +26,47 @@ synchronized_at: 2026-09-22T02:56:36Z
|
||||
|
||||
## 当前线上拓扑
|
||||
|
||||
- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。
|
||||
- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。
|
||||
- GoAuto 服务监听:`127.0.0.1:8010`。
|
||||
- 常驻服务:systemd `goauto.service`。
|
||||
- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。
|
||||
- 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。
|
||||
- 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。
|
||||
- Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。
|
||||
|
||||
## Nginx 入口(9527)
|
||||
|
||||
线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 9527 default_server;
|
||||
server_name 122.228.200.167 _;
|
||||
root /home/goauto/current/dist;
|
||||
index index.html;
|
||||
client_max_body_size 100m;
|
||||
allow all;
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
location / {
|
||||
try_files $uri @goauto_backend;
|
||||
}
|
||||
location @goauto_backend {
|
||||
proxy_pass http://127.0.0.1:8010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。
|
||||
|
||||
## Agent HTTP 例外
|
||||
|
||||
服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置:
|
||||
@@ -33,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。
|
||||
2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。
|
||||
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200):
|
||||
- `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」;
|
||||
- 前端路由(如 `GET /login`):同样返回 Admin 的 index.html;
|
||||
- `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200;
|
||||
- `GET /api/v1/captcha`:`application/json` 且 `code=200`;
|
||||
- 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401;
|
||||
- 结构日志无 panic/fatal/1146/1054。
|
||||
4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。
|
||||
5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。
|
||||
|
||||
@@ -45,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
## 服务器迁移清单
|
||||
|
||||
更换线上服务器时逐项核对,每项都要在新机上回读确认:
|
||||
|
||||
1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload;
|
||||
2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`;
|
||||
3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度;
|
||||
4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务;
|
||||
5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致;
|
||||
6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服);
|
||||
7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录;
|
||||
8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。
|
||||
|
||||
## 回滚
|
||||
|
||||
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
|
||||
@@ -153,3 +222,27 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||
|
||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||
|
||||
## #341 售后角色与账号迁移
|
||||
|
||||
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
|
||||
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
|
||||
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
|
||||
|
||||
|
||||
## #338 退货匹配发布
|
||||
|
||||
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||
|
||||
## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346)
|
||||
|
||||
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
|
||||
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
|
||||
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
|
||||
|
||||
## 档口入库码匹配中断排错与恢复(#350)
|
||||
- 匹配后台总时限仍为 20 分钟,SYB 客户端单请求 60 秒;候选数组越界造成的进程退出不能通过增大时间解决。受控日志查看运行服务的 panic/退出/重启时间,结合匹配 processed/total 与记录状态判断中断;不导出原始生产记录。
|
||||
- 修复版本启动时在接受新任务前将旧 pending/running 匹配批次终结,未完成 pending/matching 记录设为失败,已成功结果保留;仅状态恢复,不自动匹配或 SYB 回写。
|
||||
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
|
||||
|
||||
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
|
||||
|
||||
@@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST"
|
||||
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
|
||||
// an HTML page, which is far more confusing than a plain 404.
|
||||
func InitSPARouter(engine *gin.Engine) {
|
||||
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index := filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
dist, index, ok := spaIndex()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) {
|
||||
})
|
||||
}
|
||||
|
||||
// spaIndex resolves the built frontend directory and reports whether its
|
||||
// index.html exists.
|
||||
func spaIndex() (dist, index string, ok bool) {
|
||||
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
|
||||
if dist == "" {
|
||||
dist = "dist"
|
||||
}
|
||||
index = filepath.Join(dist, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
return dist, index, false
|
||||
}
|
||||
return dist, index, true
|
||||
}
|
||||
|
||||
// registerRootRoute decides what `GET /` returns (#346).
|
||||
//
|
||||
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
|
||||
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
|
||||
// that forwarded `/` to this server (instead of serving dist itself) showed
|
||||
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
|
||||
// the 2026-09-28 server migration. The welcome page is kept only for
|
||||
// development without a dist, where the frontend runs under vite.
|
||||
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
|
||||
if _, index, ok := spaIndex(); ok {
|
||||
r.GET("/", func(c *gin.Context) { c.File(index) })
|
||||
return
|
||||
}
|
||||
if mode != "prod" {
|
||||
r.GET("/", welcome)
|
||||
}
|
||||
}
|
||||
|
||||
// isAPIPath reports whether a path belongs to the server rather than the SPA.
|
||||
func isAPIPath(path string) bool {
|
||||
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
|
||||
|
||||
@@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
|
||||
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
|
||||
// welcome page, even in non-prod modes where the welcome page used to own `/`.
|
||||
func TestRootServesSPAWhenDistExists(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dist := filepath.Join(t.TempDir(), "dist")
|
||||
if err := os.MkdirAll(dist, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(SPADirEnv, dist)
|
||||
for _, mode := range []string{"dev", "test", "prod"} {
|
||||
engine := gin.New()
|
||||
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
|
||||
InitSPARouter(engine)
|
||||
response := do(engine, http.MethodGet, "/")
|
||||
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
|
||||
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a dist (development under vite) the previous behaviour is kept:
|
||||
// welcome page outside prod, nothing registered in prod.
|
||||
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
|
||||
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
|
||||
|
||||
dev := gin.New()
|
||||
registerRootRoute(dev, "dev", welcome)
|
||||
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
|
||||
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
prod := gin.New()
|
||||
registerRootRoute(prod, "prod", welcome)
|
||||
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod without dist should not register /, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) {
|
||||
go ws.WebsocketManager.SendService()
|
||||
go ws.WebsocketManager.SendAllService()
|
||||
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
r.GET("/", apis.GoAdmin)
|
||||
}
|
||||
registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin)
|
||||
r.GET("/info", handler.Ping)
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||
return ModuleYeekeReturns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
|
||||
return ModuleSYBProducts
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package access
|
||||
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
|
||||
const RolePurchaser = "purchaser"
|
||||
|
||||
// RoleAfterSales is the fixed role key for the internal after-sales users.
|
||||
const RoleAfterSales = "after_sales"
|
||||
|
||||
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
|
||||
// APIs that the purchaser role may call; every other API remains admin-only.
|
||||
type APIPermission struct {
|
||||
@@ -132,6 +135,15 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||
|
||||
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
|
||||
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
|
||||
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
|
||||
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
|
||||
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
|
||||
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
|
||||
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
|
||||
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
|
||||
|
||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||
|
||||
@@ -41,6 +41,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseRun{},
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBSessionAuthLease{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
|
||||
@@ -504,6 +504,17 @@ type SYBSession struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SYBSessionAuthLease serializes re-authentication across sync and writeback
|
||||
// workers. Cookies and passwords never live in this table.
|
||||
type SYBSessionAuthLease struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Username string `gorm:"size:128;not null;uniqueIndex"`
|
||||
Owner string `gorm:"size:36;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
|
||||
|
||||
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
|
||||
// several Shopee product lines, and the same Shopee product can appear more
|
||||
// than once within one order at different colors/sizes/quantities — each such
|
||||
|
||||
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
if role == "admin" || role == "purchaser" || role == "after_sales" {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := allowedOperator(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
|
||||
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
|
||||
t.Fatalf("state metadata: %+v", saved)
|
||||
}
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
|
||||
t.Fatal("unrelated business facts changed")
|
||||
}
|
||||
for _, replayID := range []string{rid, uuid.NewString()} {
|
||||
|
||||
@@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
|
||||
return nil
|
||||
}
|
||||
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
|
||||
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).Error
|
||||
}
|
||||
|
||||
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
|
||||
|
||||
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
||||
t.Fatal("automatically repeated write")
|
||||
}
|
||||
after := loadBackfillTask(t, s.DB, task.ID)
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
||||
wantTaskWriteback := models.PurchaseWritebackStatusFailed
|
||||
if tc.want == "succeeded" {
|
||||
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
|
||||
t.Fatal("changed purchase/payment/logistics facts")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string {
|
||||
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
|
||||
}
|
||||
|
||||
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
|
||||
// only. It never logs in, never triggers OCR and never deletes the cached
|
||||
// session (that stays the exclusive responsibility of sybimport.Connect's
|
||||
// login/refresh path) — it only reports whether the cached cookies still
|
||||
// work, via CheckSession, so the caller can classify the failure (#330).
|
||||
// restoreOrderWritebackClient uses the same session acquisition path as SYB
|
||||
// sync. A valid cached session is reused; an explicitly invalid session is
|
||||
// refreshed under the shared database auth lease so concurrent workers do not
|
||||
// request multiple captcha codes.
|
||||
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
|
||||
cfg := config.ExtConfig.SYB.Resolved()
|
||||
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.UserID <= 0 {
|
||||
return nil, errSessionUserIDMissing
|
||||
}
|
||||
c, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
|
||||
// imports cleanly and only fails later inside read(), which would record
|
||||
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
|
||||
// Any error here — ErrSessionInvalid or network/format — is treated as
|
||||
// session-class; only ErrSessionInvalid is a confirmed logout, but a
|
||||
// network/format error is not confirmed-valid either, so it is still
|
||||
// retried rather than attempted as a write.
|
||||
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
|
||||
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// One short-lived claim at a time across processes. No business writes occur
|
||||
@@ -184,7 +162,20 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
if status == "succeeded" {
|
||||
updates["completed_at"] = w.Now()
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the admin task state aligned with the authoritative writeback
|
||||
// record. Conflicts and unknown outcomes remain failed until a human
|
||||
// resolves them; they must never appear as successful.
|
||||
taskStatus := models.PurchaseWritebackStatusFailed
|
||||
if status == "succeeded" {
|
||||
taskStatus = models.PurchaseWritebackStatusSucceeded
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
|
||||
"writeback_status": taskStatus,
|
||||
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
|
||||
}).Error
|
||||
}
|
||||
// finishSessionUnavailable is the bounded-retry counterpart of finish for
|
||||
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
|
||||
@@ -201,7 +192,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
|
||||
} else {
|
||||
updates["lease_expires_at"] = nil
|
||||
}
|
||||
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
|
||||
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedCancelMatch(t *testing.T, db *gorm.DB, status string) models.ReturnMatch {
|
||||
t.Helper()
|
||||
syb := seedSYB(t, db, "CANCEL-TEST", 1, "白色", "L", time.Now())
|
||||
item := seedReturn(t, db, "白色,L", nil)
|
||||
m := models.ReturnMatch{SYBProductID: syb.ID, YeekeReturnItemID: item.ID, ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &item.ID, Status: status, MatchedAt: time.Now()}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCancelExpectedStatusHTTP(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, body, role string
|
||||
code int
|
||||
}{
|
||||
{"matched", "matched", `{"expectedStatus":"matched"}`, "purchaser", 200},
|
||||
{"confirmed_conflict", "confirmed", `{"expectedStatus":"matched"}`, "after_sales", 409},
|
||||
{"cancelled_conflict", "cancelled", `{"expectedStatus":"matched"}`, "admin", 409},
|
||||
{"legacy_empty", "confirmed", "", "after_sales", 200},
|
||||
{"legacy_object", "confirmed", `{}`, "admin", 200},
|
||||
{"invalid_status", "matched", `{"expectedStatus":"confirmed"}`, "admin", 400},
|
||||
{"invalid_empty", "matched", `{"expectedStatus":""}`, "admin", 400},
|
||||
{"invalid_null", "matched", `{"expectedStatus":null}`, "admin", 400},
|
||||
{"malformed", "matched", `{`, "admin", 400},
|
||||
{"forbidden", "matched", `{"expectedStatus":"matched"}`, "other", 403},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, tc.status)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/cancel", m.ID), strings.NewReader(tc.body))
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "cancel-tester"})
|
||||
Handler{DB: db}.Cancel(c)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
var logs int64
|
||||
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
|
||||
if tc.code == 200 {
|
||||
if saved.Status != "cancelled" || saved.ActiveSYBProductID != nil || saved.ActiveYeekeReturnItemID != nil || saved.CancelledBy != "cancel-tester" || saved.CancelledAt == nil || logs != 1 {
|
||||
t.Fatalf("cancel or audit incomplete: %+v logs=%d", saved, logs)
|
||||
}
|
||||
_, err := NewService(db).Cancel(context.Background(), m.ID, "repeat", "matched")
|
||||
if err != errStateConflict {
|
||||
t.Fatalf("repeat err=%v", err)
|
||||
}
|
||||
} else if saved.Status != tc.status || logs != 0 {
|
||||
t.Fatalf("rejected transition mutated state: %+v logs=%d", saved, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelExpectedStatusMissingAndConfirmedRace(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.Cancel(context.Background(), 99999, "tester", "matched"); err != gorm.ErrRecordNotFound {
|
||||
t.Fatalf("missing err=%v", err)
|
||||
}
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if _, err := s.Confirm(context.Background(), m.ID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A batch holding the earlier matched snapshot must not undo a completed confirmation.
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "batch", "matched"); err != errStateConflict {
|
||||
t.Fatalf("stale batch err=%v", err)
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
if saved.Status != "confirmed" || saved.ActiveSYBProductID == nil {
|
||||
t.Fatalf("confirmation lost: %+v", saved)
|
||||
}
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "legacy"); err != nil {
|
||||
t.Fatalf("legacy confirmed cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelAuditFailureRollsBackTransition(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_cancel_log", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
|
||||
tx.AddError(errors.New("synthetic audit failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(db).Cancel(context.Background(), m.ID, "tester", "matched"); err == nil {
|
||||
t.Fatal("expected audit failure")
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
if err := db.First(&saved, m.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Status != "matched" || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || saved.CancelledAt != nil {
|
||||
t.Fatalf("failed audit did not roll back: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #349 reuses the existing single-record transaction; no production batch API.
|
||||
func TestConfirmBatchExistingHTTPContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, role string
|
||||
code int
|
||||
}{
|
||||
{"admin", "matched", "admin", 200},
|
||||
{"purchaser", "matched", "purchaser", 200},
|
||||
{"after_sales", "matched", "after_sales", 200},
|
||||
{"already_confirmed", "confirmed", "after_sales", 409},
|
||||
{"cancelled", "cancelled", "purchaser", 409},
|
||||
{"forbidden", "matched", "viewer", 403},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, tc.status)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"})
|
||||
Handler{DB: db}.Confirm(c)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
var logs int64
|
||||
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
|
||||
if tc.code == 200 {
|
||||
if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 {
|
||||
t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs)
|
||||
}
|
||||
} else if saved.Status != tc.status || logs != 0 {
|
||||
t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound {
|
||||
t.Fatalf("missing=%v", err)
|
||||
}
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a later rematch with the same product/return, but a new identity.
|
||||
replacement := m
|
||||
replacement.ID = 0
|
||||
replacement.CancelledAt = nil
|
||||
replacement.CancelledBy = ""
|
||||
if err := db.Create(&replacement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict {
|
||||
t.Fatalf("stale original=%v", err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict {
|
||||
t.Fatalf("duplicate=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmBatchAuditFailureRollsBack(t *testing.T) {
|
||||
db := testDB(t)
|
||||
m := seedCancelMatch(t, db, "matched")
|
||||
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
|
||||
tx.AddError(errors.New("synthetic audit failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil {
|
||||
t.Fatal("expected audit failure")
|
||||
}
|
||||
var saved models.ReturnMatch
|
||||
db.First(&saved, m.ID)
|
||||
if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil {
|
||||
t.Fatalf("confirm did not roll back: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -44,14 +46,14 @@ func operatorFromContext(c *gin.Context) (string, string) {
|
||||
return role, username
|
||||
}
|
||||
|
||||
// requireCanPurchase mirrors the admin/purchaser write gate this codebase
|
||||
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
|
||||
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
|
||||
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
|
||||
// writes and require it; the two list/detail read endpoints do not.
|
||||
func requireCanPurchase(c *gin.Context) bool {
|
||||
role, _ := operatorFromContext(c)
|
||||
if role != "admin" && role != "purchaser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"})
|
||||
if role != "admin" && role != "purchaser" && role != "after_sales" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -77,6 +79,10 @@ func (h Handler) BatchMatch(c *gin.Context) {
|
||||
}
|
||||
_, operator := operatorFromContext(c)
|
||||
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
|
||||
if errors.Is(err, ErrTooManyItems) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
||||
return
|
||||
@@ -136,7 +142,19 @@ func (h Handler) Confirm(c *gin.Context) {
|
||||
|
||||
func (h Handler) Cancel(c *gin.Context) {
|
||||
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
|
||||
return s.Cancel(ctx.Request.Context(), id, operator)
|
||||
var body map[string]json.RawMessage
|
||||
if err := ctx.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
raw, supplied := body["expectedStatus"]
|
||||
if !supplied {
|
||||
return s.Cancel(ctx.Request.Context(), id, operator)
|
||||
}
|
||||
var expectedStatus string
|
||||
if err := json.Unmarshal(raw, &expectedStatus); err != nil {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
return s.Cancel(ctx.Request.Context(), id, operator, expectedStatus)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +174,10 @@ func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint
|
||||
_, operator := operatorFromContext(c)
|
||||
match, err := fn(NewService(db), c, id, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidExpectedStatus) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "expectedStatus 只允许 matched,或省略请求体"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestReturnMatchWriteRoles(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := requireCanPurchase(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
deadline := time.Now().Add(24 * time.Hour)
|
||||
ret := seedReturn(t, db, "红色", &deadline)
|
||||
sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now())
|
||||
pool, err := s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 1 {
|
||||
t.Fatalf("waiting pool=%v err=%v", pool, err)
|
||||
}
|
||||
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err = s.availableReturnPool(context.Background())
|
||||
if err != nil || len(pool) != 0 {
|
||||
t.Fatalf("reshipped pool=%v err=%v", pool, err)
|
||||
}
|
||||
_, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test")
|
||||
if !errors.Is(err, errReturnNoLongerEligible) {
|
||||
t.Fatalf("final recheck=%v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.ReturnMatch{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("reshipped item was allocated")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// InitRouter mounts the #338 return-matching admin surface. List/detail are
|
||||
// readable by any authenticated admin user; the write actions (batch match,
|
||||
// confirm, cancel, remark) additionally require admin/purchaser via
|
||||
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
|
||||
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
|
||||
@@ -68,10 +68,23 @@ const (
|
||||
ReasonConflict = "conflict"
|
||||
)
|
||||
|
||||
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
|
||||
// size can now go up to 500/page, so the button's selection can exceed the
|
||||
// previously-unbounded batch-match request size).
|
||||
const maxBatchMatchItems = 500
|
||||
|
||||
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
|
||||
// maxBatchMatchItems SYB product ids; the handler turns this into an
|
||||
// INVALID_REQUEST response instead of a 500.
|
||||
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
|
||||
|
||||
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
|
||||
// ever called from the batch-match button (ticked rows) — no scheduler, no
|
||||
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
|
||||
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
|
||||
if len(req.SYBProductIDs) > maxBatchMatchItems {
|
||||
return BatchMatchResponse{}, ErrTooManyItems
|
||||
}
|
||||
resp, err := s.batchMatch(ctx, req)
|
||||
if len(req.SYBProductIDs) > 0 {
|
||||
// The batch record is written after the per-row transactions have
|
||||
@@ -225,6 +238,11 @@ func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchM
|
||||
}
|
||||
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
|
||||
if insertErr != nil {
|
||||
if errors.Is(insertErr, errReturnNoLongerEligible) {
|
||||
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"})
|
||||
resp.SkippedCount++
|
||||
continue
|
||||
}
|
||||
if errors.Is(insertErr, errStageNoLongerEligible) {
|
||||
// #338 review fix: the stage was re-checked under the same
|
||||
// FOR UPDATE lock purchase.create takes, right before insert.
|
||||
@@ -279,6 +297,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
|
||||
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
|
||||
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
|
||||
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
|
||||
Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -299,6 +318,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e
|
||||
// it between BatchMatch's outer screening pass and this point (#338 review
|
||||
// fix: race between matching and purchase creation).
|
||||
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
|
||||
var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship")
|
||||
|
||||
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
|
||||
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
|
||||
@@ -327,6 +347,17 @@ func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome Ma
|
||||
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pkg models.YeekeReturnPackage
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Same package -> item lock order as sync upsert; avoid a lock cycle.
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" {
|
||||
return errReturnNoLongerEligible
|
||||
}
|
||||
sybIDCopy := syb.ID
|
||||
returnIDCopy := outcome.ReturnItemID
|
||||
deadline := outcome.DestroyDeadline
|
||||
@@ -383,11 +414,18 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string)
|
||||
return match, err
|
||||
}
|
||||
|
||||
// Cancel restores the SYB product to purchasable (by clearing
|
||||
// ActiveSYBProductID) and returns the return item to the eligible pool (by
|
||||
// clearing ActiveYeekeReturnItemID), from either matched or confirmed state,
|
||||
// Cancel releases the active SYB product and return item pointers; actual
|
||||
// purchase readiness and return-pool eligibility are then re-evaluated.
|
||||
// Without a precondition it accepts either matched or confirmed state,
|
||||
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
|
||||
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
|
||||
var errInvalidExpectedStatus = errors.New("expectedStatus must be matched")
|
||||
|
||||
// Optional matched precondition is checked under the same row lock as cancellation.
|
||||
// Legacy callers without it retain the confirmed-to-cancelled transition.
|
||||
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string, expectedStatus ...string) (models.ReturnMatch, error) {
|
||||
if len(expectedStatus) > 1 || (len(expectedStatus) == 1 && expectedStatus[0] != models.ReturnMatchStatusMatched) {
|
||||
return models.ReturnMatch{}, errInvalidExpectedStatus
|
||||
}
|
||||
var match models.ReturnMatch
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
|
||||
@@ -396,6 +434,9 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
|
||||
if match.Status == models.ReturnMatchStatusCancelled {
|
||||
return errStateConflict
|
||||
}
|
||||
if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) {
|
||||
return errStateConflict
|
||||
}
|
||||
now := s.Now()
|
||||
match.Status = models.ReturnMatchStatusCancelled
|
||||
match.ActiveSYBProductID = nil
|
||||
@@ -405,7 +446,7 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (
|
||||
if err := tx.Save(&match).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error
|
||||
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,释放 SYB 与退货商品占用,采购准备状态重新计算"}).Error
|
||||
})
|
||||
return match, err
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color
|
||||
func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem {
|
||||
t.Helper()
|
||||
pkg := models.YeekeReturnPackage{
|
||||
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
|
||||
ClaimStatus: "1",
|
||||
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
|
||||
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&pkg).Error; err != nil {
|
||||
@@ -111,6 +112,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
ids := make([]uint64, maxBatchMatchItems+1)
|
||||
for i := range ids {
|
||||
ids[i] = uint64(i + 1)
|
||||
}
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
|
||||
if !errors.Is(err, ErrTooManyItems) {
|
||||
t.Fatalf("expected ErrTooManyItems, got %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("expected empty response on rejection, got %+v", resp)
|
||||
}
|
||||
|
||||
var batchCount int64
|
||||
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batchCount != 0 {
|
||||
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
|
||||
|
||||
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// DB exposes the store connection to the shared session manager; callers do
|
||||
// not receive any session data through this accessor.
|
||||
func (s *SessionStore) DB() *gorm.DB { return s.db }
|
||||
|
||||
// Session is one cached SYB login.
|
||||
type Session struct {
|
||||
Username string
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LoginConfig contains the non-secret connection settings and credentials
|
||||
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
|
||||
type LoginConfig struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
OcrURL string
|
||||
OcrMaxAttempts int
|
||||
}
|
||||
|
||||
// AcquireSession reuses a valid cached session and performs at most one
|
||||
// re-login per account at a time. Callers waiting for another process to
|
||||
// refresh the session never request another captcha.
|
||||
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
|
||||
if cfg.Username == "" {
|
||||
return nil, fmt.Errorf("顺云宝账号未配置")
|
||||
}
|
||||
client, err := New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := NewSessionStore(db)
|
||||
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
|
||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
|
||||
return nil, importErr
|
||||
}
|
||||
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
|
||||
if checkErr == nil {
|
||||
return client, nil
|
||||
}
|
||||
if !errors.Is(checkErr, ErrSessionInvalid) {
|
||||
return nil, checkErr
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
return nil, checkErr
|
||||
}
|
||||
} else if !errors.Is(loadErr, ErrNoSession) {
|
||||
return nil, loadErr
|
||||
} else if cfg.Password == "" {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
owner := uuid.NewString()
|
||||
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
|
||||
return nil, err
|
||||
} else if claimed {
|
||||
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
|
||||
// Another worker may have completed login between our first probe and
|
||||
// acquiring the lease; always re-check before requesting a captcha.
|
||||
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||
return c, nil
|
||||
}
|
||||
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validSessionOrError(ctx, client, store, cfg.Username)
|
||||
}
|
||||
|
||||
// A different worker owns the login lease. Wait for its session, bounded by
|
||||
// the caller's context; do not trigger a second login.
|
||||
for {
|
||||
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
|
||||
return c, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
|
||||
s, err := store.Load(ctx, username, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
|
||||
s, err := store.Load(ctx, username, time.Now())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
|
||||
// Network failures are not treated as logout, but they also do not
|
||||
// authorize a write; the caller will retry through the normal worker.
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
|
||||
ocr, err := NewOcrClient(cfg.OcrURL, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
|
||||
}
|
||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||
if result == nil {
|
||||
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||
}
|
||||
cookies, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
|
||||
}
|
||||
|
||||
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
|
||||
now := time.Now().UTC()
|
||||
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
|
||||
return r.RowsAffected == 1, r.Error
|
||||
}
|
||||
|
||||
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
|
||||
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sybclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("first owner should claim: %v %v", first, err)
|
||||
}
|
||||
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||
if err != nil || second {
|
||||
t.Fatalf("second owner must wait: %v %v", second, err)
|
||||
}
|
||||
releaseAuthLease(ctx, db, "operator", "first")
|
||||
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
|
||||
if err != nil || !second {
|
||||
t.Fatalf("lease should be reusable: %v %v", second, err)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
|
||||
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchase"
|
||||
@@ -44,6 +45,8 @@ type ListRequest struct {
|
||||
OrderCodes []string
|
||||
ParseStatus string
|
||||
ProcessStage string
|
||||
CreatedFrom string
|
||||
CreatedTo string
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
@@ -65,10 +68,20 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 20
|
||||
}
|
||||
if request.PageSize > 100 {
|
||||
request.PageSize = 100
|
||||
if request.PageSize > 500 {
|
||||
request.PageSize = 500
|
||||
}
|
||||
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
|
||||
createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
|
||||
if err != nil {
|
||||
return ListResponse{}, err
|
||||
}
|
||||
if createdFrom != nil {
|
||||
query = query.Where("created_at >= ?", *createdFrom)
|
||||
}
|
||||
if createdTo != nil {
|
||||
query = query.Where("created_at < ?", *createdTo)
|
||||
}
|
||||
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
||||
if len([]rune(request.ShopName)) > 255 {
|
||||
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
|
||||
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
|
||||
}
|
||||
|
||||
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
|
||||
// range. The bounds are interpreted in the server's local timezone, matching
|
||||
// the timestamps written by GORM for this service.
|
||||
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
|
||||
from = strings.TrimSpace(from)
|
||||
to = strings.TrimSpace(to)
|
||||
if from == "" && to == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
parse := func(value, label string) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
start, err := parse(from, "createdFrom")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
endDay, err := parse(to, "createdTo")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if start != nil && endDay != nil && start.After(*endDay) {
|
||||
return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
|
||||
}
|
||||
if endDay != nil {
|
||||
end := endDay.AddDate(0, 0, 1)
|
||||
endDay = &end
|
||||
}
|
||||
return start, endDay, nil
|
||||
}
|
||||
|
||||
func normalizeOrderCodes(raw []string) ([]string, error) {
|
||||
seen := make(map[string]bool, len(raw))
|
||||
result := make([]string, 0, len(raw))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
@@ -93,6 +94,76 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i := 0; i < 3; i++ {
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
|
||||
t.Fatalf("apply detail %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("list with oversized page size: %v", err)
|
||||
}
|
||||
if overLimit.PageSize != 500 {
|
||||
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
|
||||
}
|
||||
|
||||
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
|
||||
if err != nil {
|
||||
t.Fatalf("list at exactly 500: %v", err)
|
||||
}
|
||||
if within.PageSize != 500 {
|
||||
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
|
||||
rowOrder := order
|
||||
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
|
||||
rowOrder.StockID += uint64(i)
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
|
||||
if err != nil || between.Total != 2 {
|
||||
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
|
||||
}
|
||||
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
|
||||
if err != nil || fromOnly.Total != 1 {
|
||||
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
|
||||
}
|
||||
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
|
||||
if err != nil || toOnly.Total != 1 {
|
||||
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
|
||||
}
|
||||
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
|
||||
t.Fatalf("reversed created date range should be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
@@ -480,54 +480,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) {
|
||||
// blip as a logout would trigger needless logins and could throw away a
|
||||
// perfectly good session.
|
||||
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
|
||||
}
|
||||
client, err := sybclient.New(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached, err := store.Load(ctx, cfg.Username, time.Now())
|
||||
switch {
|
||||
case err == nil:
|
||||
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
|
||||
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
|
||||
return client, nil
|
||||
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
|
||||
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
|
||||
return nil, delErr
|
||||
}
|
||||
}
|
||||
}
|
||||
case errors.Is(err, sybclient.ErrNoSession):
|
||||
// Nothing cached; fall through to a fresh login.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.OcrURL == "" {
|
||||
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
|
||||
}
|
||||
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
|
||||
}
|
||||
jar, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Save(ctx, sybclient.Session{
|
||||
Username: cfg.Username, UserID: result.User.ID,
|
||||
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{
|
||||
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
|
||||
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectConfig carries the login settings. The password is passed through and
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type MatchReader interface {
|
||||
@@ -44,12 +46,19 @@ func (m Matcher) runBackground(db *gorm.DB, jobID string) {
|
||||
err = RunMatchJob(ctx, db, reader, jobID)
|
||||
}
|
||||
if err != nil {
|
||||
now := time.Now().UTC()
|
||||
_ = db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status IN ?", jobID, []string{"pending", "running"}).Updates(map[string]any{"status": "failed", "error_message": compact(err.Error(), 1000), "finished_at": now}).Error
|
||||
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error {
|
||||
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
|
||||
claimedJob := false
|
||||
defer func() {
|
||||
if runErr != nil && claimedJob {
|
||||
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
|
||||
runErr = errors.Join(runErr, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
now := time.Now().UTC()
|
||||
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
|
||||
if claimed.Error != nil {
|
||||
@@ -65,6 +74,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
return conflict("匹配任务状态不允许执行")
|
||||
}
|
||||
claimedJob = true
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
@@ -74,7 +84,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
return fmt.Errorf("匹配任务记录范围无效")
|
||||
}
|
||||
var records []models.SYBInnerCodeRecord
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
used, err := loadReservedDetails(ctx, db, records)
|
||||
@@ -83,7 +93,10 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
}
|
||||
ready, failed := 0, 0
|
||||
for _, record := range records {
|
||||
plan, status, message, planErr := planRecord(ctx, reader, record, used)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
|
||||
if planErr != nil {
|
||||
status = models.SYBInnerCodeFailed
|
||||
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
|
||||
@@ -110,12 +123,62 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed})
|
||||
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
|
||||
}
|
||||
|
||||
// Only the read/plan step is isolated: no remote write is retried here.
|
||||
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
|
||||
err = nil
|
||||
}
|
||||
}()
|
||||
return planRecord(ctx, reader, record, used)
|
||||
}
|
||||
|
||||
// Cleanup must not inherit an expired job or HTTP request context.
|
||||
func failMatchJob(db *gorm.DB, jobID, message string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status != "pending" && job.Status != "running" {
|
||||
return nil
|
||||
}
|
||||
var ids []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Called at startup before new jobs can be submitted; never resumes remote writes.
|
||||
func RecoverInterruptedMatches(db *gorm.DB) error {
|
||||
var jobs []models.SYBInnerCodeMatchJob
|
||||
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
|
||||
selectedIDs := map[uint64]bool{}
|
||||
for _, r := range selected {
|
||||
@@ -185,6 +248,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
if reason != "" {
|
||||
return nil, models.SYBInnerCodeSkipped, reason, nil
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
|
||||
}
|
||||
count := len(record.Items)
|
||||
if count == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
|
||||
@@ -193,18 +259,28 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
if len(matches) == 1 && matches[0].ProductQty == count {
|
||||
chosen = []sybclient.DetailItem{matches[0]}
|
||||
} else if len(matches) == count && count > 1 {
|
||||
sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID })
|
||||
for _, item := range matches {
|
||||
if item.ProductQty != 1 {
|
||||
return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil
|
||||
}
|
||||
}
|
||||
chosen = matches
|
||||
codes := make([]string, count)
|
||||
for i, it := range record.Items {
|
||||
codes[i] = it.Code
|
||||
}
|
||||
assigned, reason := assignExistingBoundItems(record.Stall, codes, matches)
|
||||
if reason != "" {
|
||||
return nil, models.SYBInnerCodeSkipped, reason, nil
|
||||
}
|
||||
chosen = assigned
|
||||
} else if len(matches) > 1 {
|
||||
return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil
|
||||
} else {
|
||||
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
|
||||
}
|
||||
if len(chosen) == 0 {
|
||||
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
|
||||
}
|
||||
primary := chosen[0]
|
||||
items := make([]plannedRemoteItem, 0, count)
|
||||
placeholder := 0
|
||||
@@ -246,6 +322,83 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
|
||||
return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil
|
||||
}
|
||||
|
||||
// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。
|
||||
// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标
|
||||
// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选
|
||||
// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未
|
||||
// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。
|
||||
// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充):
|
||||
// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在
|
||||
// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配;
|
||||
// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效
|
||||
// 或重复的商品明细 ID。
|
||||
func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
|
||||
sorted := append([]sybclient.DetailItem(nil), matches...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||
seenIDs := make(map[int64]bool, len(sorted))
|
||||
for _, item := range sorted {
|
||||
if item.ID <= 0 || seenIDs[item.ID] {
|
||||
return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配"
|
||||
}
|
||||
seenIDs[item.ID] = true
|
||||
}
|
||||
first := sorted[0]
|
||||
wantSpec := first.ProductSpec
|
||||
wantSKU := rawText(first.Raw["sku"])
|
||||
wantVariationSKU := rawText(first.Raw["variationSku"])
|
||||
for _, item := range sorted {
|
||||
if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU {
|
||||
return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(stall) != "" {
|
||||
for _, item := range sorted {
|
||||
if !stallMatches(stall, item) {
|
||||
return nil, "重复候选的档口及货号不一致,不能自动逐件分配"
|
||||
}
|
||||
}
|
||||
}
|
||||
assigned := make([]sybclient.DetailItem, len(codes))
|
||||
taken := make([]bool, len(codes))
|
||||
codeIndex := make(map[string]int, len(codes))
|
||||
for i, code := range codes {
|
||||
codeIndex[code] = i
|
||||
}
|
||||
blanks := make([]int, 0, len(sorted))
|
||||
for si, item := range sorted {
|
||||
remote := rawText(item.Raw["innerExpCode"])
|
||||
if remote == "" {
|
||||
blanks = append(blanks, si)
|
||||
continue
|
||||
}
|
||||
idx, ok := codeIndex[remote]
|
||||
if !ok {
|
||||
return nil, "候选商品明细存在非目标入库码,不能自动逐件分配"
|
||||
}
|
||||
if taken[idx] {
|
||||
return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配"
|
||||
}
|
||||
assigned[idx] = item
|
||||
taken[idx] = true
|
||||
}
|
||||
bi := 0
|
||||
for i := range assigned {
|
||||
if taken[i] {
|
||||
continue
|
||||
}
|
||||
if bi >= len(blanks) {
|
||||
return nil, "现成空白明细不足,不能完成逐件分配"
|
||||
}
|
||||
assigned[i] = sorted[blanks[bi]]
|
||||
taken[i] = true
|
||||
bi++
|
||||
}
|
||||
if bi != len(blanks) {
|
||||
return nil, "现成空白明细多于待写入入库码,不能自动逐件分配"
|
||||
}
|
||||
return assigned, ""
|
||||
}
|
||||
|
||||
func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem {
|
||||
result := []sybclient.DetailItem{}
|
||||
for _, item := range items {
|
||||
@@ -264,12 +417,18 @@ func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Compare layout-only controls without changing stored evidence or ordinary spaces.
|
||||
func normalizeMatchLayout(value string) string {
|
||||
return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value))
|
||||
}
|
||||
|
||||
func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
|
||||
sourceSKU = strings.TrimSpace(sourceSKU)
|
||||
sourceSKU = normalizeMatchLayout(sourceSKU)
|
||||
if sourceSKU != "" {
|
||||
skuMatches := []sybclient.DetailItem{}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU {
|
||||
skuMatches = append(skuMatches, item)
|
||||
}
|
||||
}
|
||||
@@ -300,20 +459,140 @@ func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]syb
|
||||
return fallback, ""
|
||||
}
|
||||
func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem {
|
||||
stall = strings.TrimSpace(stall)
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return nil
|
||||
}
|
||||
result := []sybclient.DetailItem{}
|
||||
name, article, has := strings.Cut(stall, "#")
|
||||
for _, item := range items {
|
||||
blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) {
|
||||
if stallMatches(stall, item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复):
|
||||
// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求
|
||||
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
|
||||
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
|
||||
func stallMatches(stall string, item sybclient.DetailItem) bool {
|
||||
stall = normalizeMatchLayout(stall)
|
||||
if stall == "" {
|
||||
return false
|
||||
}
|
||||
sku := normalizeMatchLayout(rawText(item.Raw["sku"]))
|
||||
variation := normalizeMatchLayout(rawText(item.Raw["variationSku"]))
|
||||
blob := sku + " " + variation + " " + item.ProductSpec
|
||||
if strings.Contains(blob, stall) {
|
||||
return true
|
||||
}
|
||||
name, article, hasArticle := splitStall(stall)
|
||||
if !hasArticle {
|
||||
return false
|
||||
}
|
||||
if article == "" {
|
||||
return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
|
||||
}
|
||||
if isNumericArticle(article) {
|
||||
nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
|
||||
if !nameMatches {
|
||||
return false
|
||||
}
|
||||
return textHasNumericArticle(sku, article) ||
|
||||
textHasNumericArticle(variation, article) ||
|
||||
productSpecStartsWithArticle(item.ProductSpec, article, true)
|
||||
}
|
||||
return textHasExactArticle(sku, article) ||
|
||||
textHasExactArticle(variation, article) ||
|
||||
productSpecStartsWithArticle(item.ProductSpec, article, false)
|
||||
}
|
||||
|
||||
// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。
|
||||
func splitStall(stall string) (name, article string, ok bool) {
|
||||
stall = strings.TrimSpace(stall)
|
||||
separator := strings.LastIndex(stall, "#")
|
||||
if separator < 0 {
|
||||
return stall, "", false
|
||||
}
|
||||
return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true
|
||||
}
|
||||
|
||||
func isNumericArticle(article string) bool {
|
||||
if article == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range article {
|
||||
if !unicode.IsDigit(char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func textHasNumericArticle(text, article string) bool {
|
||||
target := normalizeNumericArticle(article)
|
||||
for _, token := range articleTokens(text) {
|
||||
if isNumericArticle(token) && normalizeNumericArticle(token) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeNumericArticle(article string) string {
|
||||
normalized := strings.TrimLeft(article, "0")
|
||||
if normalized == "" {
|
||||
return "0"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func textHasExactArticle(text, article string) bool {
|
||||
for _, token := range articleTokens(text) {
|
||||
if token == article {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool {
|
||||
productSpec = strings.TrimSpace(productSpec)
|
||||
separator := strings.IndexAny(productSpec, " ,,")
|
||||
if separator <= 0 {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSpace(productSpec[:separator])
|
||||
if numeric {
|
||||
return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article)
|
||||
}
|
||||
return prefix == article
|
||||
}
|
||||
|
||||
// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为
|
||||
// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。
|
||||
func articleTokens(text string) []string {
|
||||
tokens := make([]string, 0)
|
||||
start := -1
|
||||
runes := []rune(text)
|
||||
for index, char := range runes {
|
||||
if unicode.IsLetter(char) || unicode.IsDigit(char) {
|
||||
if start < 0 {
|
||||
start = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start >= 0 {
|
||||
tokens = append(tokens, string(runes[start:index]))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
tokens = append(tokens, string(runes[start:]))
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
func rawText(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
func TestMatchEvidenceNormalizesLayoutOnBothSides(t *testing.T) {
|
||||
for _, layout := range []string{"\n", "\r", "\r\n", "\t"} {
|
||||
for _, sourceHasLayout := range []bool{true, false} {
|
||||
source, candidate := "市场-档口甲#731", "市场-档口甲#731"
|
||||
if sourceHasLayout {
|
||||
source = "市场-" + layout + "档口甲#731"
|
||||
} else {
|
||||
candidate = "市场-" + layout + "档口甲#731"
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, reason := matchEvidence(source, source, []sybclient.DetailItem{item})
|
||||
if reason != "" || len(matches) != 1 || !stallMatches(source, item) {
|
||||
t.Fatalf("layout=%q sourceLayout=%v: matches=%v reason=%s", layout, sourceHasLayout, matches, reason)
|
||||
}
|
||||
if item.Raw["variationSku"] != candidate {
|
||||
t.Fatal("comparison modified raw candidate evidence")
|
||||
}
|
||||
}
|
||||
}
|
||||
item := detail(1, "黑色,L", 1, "SKU-\n1", "", "")
|
||||
if matches, reason := matchEvidence("", "SKU-1", []sybclient.DetailItem{item}); reason != "" || len(matches) != 1 {
|
||||
t.Fatalf("raw sku layout did not match: %v %s", matches, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLayoutPreservesIdentityAndAmbiguityChecks(t *testing.T) {
|
||||
for _, candidate := range []string{"市场-档口乙#731", "市场-档口甲#732", "市场-档 口甲#731", "市场-档口甲#PDD7319"} {
|
||||
item := detail(1, "黑色,L", 1, "", candidate, "")
|
||||
matches, _ := matchEvidence("市场-\n档口甲#731", "市场-\n档口甲#731", []sybclient.DetailItem{item})
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("different identity unexpectedly matched: %q", candidate)
|
||||
}
|
||||
}
|
||||
items := []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "", ""), detail(2, "黑色,L", 1, "SKU-\n1", "", "")}
|
||||
if matches, reason := matchEvidence("", "SKU-1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("layout-equivalent duplicate SKUs must remain ambiguous")
|
||||
}
|
||||
items = []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "市场-档口乙#732", ""), detail(2, "黑色,L", 1, "OTHER", "市场-档口甲#731", "")}
|
||||
if matches, reason := matchEvidence("市场-\n档口甲#731", "SKU-\t1", items); len(matches) != 0 || reason == "" {
|
||||
t.Fatal("SKU/stall conflict must remain rejected")
|
||||
}
|
||||
if normalizeMatchLayout("A B") == normalizeMatchLayout("AB") || normalizeMatchLayout("a") == normalizeMatchLayout("A") {
|
||||
t.Fatal("ordinary spaces and case must remain significant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRecordWithLayoutEvidenceKeepsOriginalValues(t *testing.T) {
|
||||
record := matchRecord("ORDER-1", "市场-\n档口甲#731", "市场-\n档口甲#731", "IC-1")
|
||||
item := detail(20, "黑色,L", 1, "", "市场-档口甲#731", "")
|
||||
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{item}}}}
|
||||
plan, status, reason, err := planRecord(context.Background(), reader, record, map[int64]bool{})
|
||||
if err != nil || status != models.SYBInnerCodeReady || plan == nil || plan.DetailID != 20 {
|
||||
t.Fatalf("expected unique ready plan, got %v %s %s %v", plan, status, reason, err)
|
||||
}
|
||||
if record.Stall != "市场-\n档口甲#731" || record.SourceSKURaw != record.Stall || plan.SYBVariationSKU != "市场-档口甲#731" {
|
||||
t.Fatal("original import and plan evidence must remain unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sybinnercode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
)
|
||||
|
||||
type interruptingReader struct {
|
||||
*fakeMatchReader
|
||||
panicOrder string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r interruptingReader) ListByOrderNumber(ctx context.Context, order string) ([]sybclient.StockRow, error) {
|
||||
if order == r.panicOrder {
|
||||
panic("must not escape or be persisted")
|
||||
}
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return r.fakeMatchReader.ListByOrderNumber(ctx, order)
|
||||
}
|
||||
|
||||
func TestEmptyEvidenceDoesNotPanicAndNextRecordContinues(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("BAD", "NO-SKU", "NO#9", "IC-1"), matchRecord("GOOD", "SKU-A", "A#1", "IC-2")})
|
||||
r := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"BAD": {{ID: 10}}, "GOOD": {{ID: 11}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", "")}}, 11: {ID: 11, Details: []sybclient.DetailItem{detail(21, "黑色,L", 1, "SKU-A", "A#1", "")}}}}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeSkipped || rows[1].Status != models.SYBInnerCodeReady {
|
||||
t.Fatal("empty evidence must be limited, next row ready")
|
||||
}
|
||||
var plans int64
|
||||
db.Model(&models.SYBInnerCodePlan{}).Count(&plans)
|
||||
if plans != 1 {
|
||||
t.Fatalf("unexpected plans=%d", plans)
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 || j.Ready != 1 || j.Failed != 1 || j.Status != "succeeded" {
|
||||
t.Fatalf("counts=%+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRecordPanicIsIsolated(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("PANIC", "S", "A", "IC-1"), matchRecord("NEXT", "S", "A", "IC-2")})
|
||||
r := interruptingReader{fakeMatchReader: &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, panicOrder: "PANIC"}
|
||||
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeFailed || rows[0].ResultMessage != "匹配处理异常,请重新匹配" || rows[1].Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("panic was not safely persisted or next row not processed")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Processed != 2 {
|
||||
t.Fatal("remaining row was not processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledMatchPersistsFailureWithIndependentContext(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := RunMatchJob(ctx, db, interruptingReader{fakeMatchReader: &fakeMatchReader{}, cancel: cancel}, job)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeFailed {
|
||||
t.Fatal("cancel left pending row")
|
||||
}
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("cancel left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartRecoveryPreservesSuccessfulRecordsAndScope(t *testing.T) {
|
||||
db := testDB(t)
|
||||
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
|
||||
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", job).Update("status", "running")
|
||||
db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "A").Update("status", models.SYBInnerCodeUpdated)
|
||||
outside := matchRecord("OUTSIDE", "S", "C", "IC-3")
|
||||
db.Create(&outside)
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RecoverInterruptedMatches(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Order("id").Find(&rows)
|
||||
if rows[0].Status != models.SYBInnerCodeUpdated || rows[1].Status != models.SYBInnerCodeFailed || rows[2].Status != models.SYBInnerCodePending {
|
||||
t.Fatal("recovery altered success or outside scope")
|
||||
}
|
||||
var j models.SYBInnerCodeMatchJob
|
||||
db.First(&j, "id = ?", job)
|
||||
if j.Status != "failed" || j.FinishedAt == nil {
|
||||
t.Fatal("recovery left running job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRematchClaimsPendingFailedSkippedAndIsIdempotent(t *testing.T) {
|
||||
db := testDB(t)
|
||||
statuses := []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}
|
||||
ids := []uint64{}
|
||||
for i, status := range statuses {
|
||||
r := matchRecord(string(rune('A'+i)), "S", "A", string(rune('X'+i)))
|
||||
r.Status = status
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
s := NewService(db)
|
||||
req := RematchRequest{RequestID: uuid.NewString(), IDs: ids}
|
||||
result, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || result.Queued != 3 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
replay, err := s.QueueRematch(context.Background(), req)
|
||||
if err != nil || replay.MatchJobID != result.MatchJobID {
|
||||
t.Fatal("idempotent replay failed")
|
||||
}
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("overlapping job accepted")
|
||||
}
|
||||
var rows []models.SYBInnerCodeRecord
|
||||
db.Find(&rows)
|
||||
for _, r := range rows {
|
||||
if r.Status != models.SYBInnerCodeMatching {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
}
|
||||
if _, err := s.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
|
||||
t.Fatal("matching records can be deleted during execution")
|
||||
}
|
||||
if err := RunMatchJob(context.Background(), db, &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, result.MatchJobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job models.SYBInnerCodeMatchJob
|
||||
db.First(&job, "id = ?", result.MatchJobID)
|
||||
if job.Processed != 3 || job.Status != "succeeded" {
|
||||
t.Fatal("claimed matching records were not executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRematchRejectsPendingOwnedByImportAndProtectedStates(t *testing.T) {
|
||||
db := testDB(t)
|
||||
createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "X")})
|
||||
var r models.SYBInnerCodeRecord
|
||||
db.First(&r)
|
||||
s := NewService(db)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatal("active import overlapped")
|
||||
}
|
||||
for _, status := range []string{models.SYBInnerCodeReady, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeNeedsCheck} {
|
||||
db.Model(&r).Update("status", status)
|
||||
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
|
||||
t.Fatalf("protected status accepted: %s", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,209 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) ---
|
||||
|
||||
func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) {
|
||||
// #273: "50公斤" must not be treated as if the article were the bare number 50.
|
||||
item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "")
|
||||
if stallMatches("档口甲#50", item) {
|
||||
t.Fatalf("weight-like text must not match numeric article 50")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) {
|
||||
// #273: a long code with internal digits (PDD256437) must not spuriously
|
||||
// match a short numeric article (256) via substring containment.
|
||||
item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "")
|
||||
if stallMatches("档口甲#256", item) {
|
||||
t.Fatalf("long code must not match numeric article 256 via substring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) {
|
||||
// #259/#273: "067" and "67" are equivalent articles only when the stall
|
||||
// name also matches; a different stall name must not match.
|
||||
sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "")
|
||||
if !stallMatches("档口甲#67", sameStall) {
|
||||
t.Fatalf("067 should be treated as equivalent to 67 when stall name matches")
|
||||
}
|
||||
differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "")
|
||||
if stallMatches("档口甲#67", differentStall) {
|
||||
t.Fatalf("067 must not match 67 when the stall name differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) {
|
||||
item := detail(1, "黑色,L", 1, "ABC12", "", "")
|
||||
if stallMatches("档口甲#AB", item) {
|
||||
t.Fatalf("non-numeric article must require an exact token match, not substring")
|
||||
}
|
||||
exact := detail(2, "黑色,L", 1, "AB", "", "")
|
||||
if !stallMatches("档口甲#AB", exact) {
|
||||
t.Fatalf("exact non-numeric token should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesHandlesHashInsideStallName(t *testing.T) {
|
||||
// Splits on the LAST '#' so a stall name that itself contains '#' still
|
||||
// yields the correct article.
|
||||
item := detail(1, "黑色,L", 1, "档口#甲-67", "", "")
|
||||
if !stallMatches("档口#甲#67", item) {
|
||||
t.Fatalf("stall name containing '#' should still resolve article via last '#'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) {
|
||||
// Non-numeric article: ProductSpec prefix match does not additionally
|
||||
// require the stall name to appear in sku/variationSku.
|
||||
item := detail(1, "AB 黑色,L", 1, "", "", "")
|
||||
if !stallMatches("档口甲#AB", item) {
|
||||
t.Fatalf("ProductSpec starting with the article should match")
|
||||
}
|
||||
notPrefix := detail(2, "黑色,ABL", 1, "", "", "")
|
||||
if stallMatches("档口甲#AB", notPrefix) {
|
||||
t.Fatalf("article appearing mid-spec (not as prefix) must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) {
|
||||
item := detail(1, "黑色,L", 1, "档口甲专柜", "", "")
|
||||
if !stallMatches("档口甲#", item) {
|
||||
t.Fatalf("empty article should fall back to stall-name containment")
|
||||
}
|
||||
}
|
||||
|
||||
// --- #289: existing-binding-preserving multi-piece assignment ---
|
||||
|
||||
func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) {
|
||||
// Two single-piece candidates already carry codes, but the previously
|
||||
// bound code (IC-2) sits on the LOWER-ID detail while the target order
|
||||
// expects it second. A naive ID-order/index assignment would strip the
|
||||
// existing correct binding from detail 20 and try to overwrite it.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", reason)
|
||||
}
|
||||
if assigned[1].ID != 20 {
|
||||
t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1])
|
||||
}
|
||||
if assigned[0].ID != 21 {
|
||||
t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) {
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason == "" {
|
||||
t.Fatalf("expected rejection for detail already holding a non-target code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) {
|
||||
// Candidates can share a NormalizeSpecKey-normalized spec while their raw
|
||||
// ProductSpec/sku/variationSku differ; auto-assignment across genuinely
|
||||
// different items must be rejected.
|
||||
d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "")
|
||||
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" {
|
||||
t.Fatalf("expected identity-mismatch rejection, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) {
|
||||
// The no-SKU fallback path in matchEvidence can hand back candidates that
|
||||
// were never checked against the stall at all. Both candidates share an
|
||||
// identical identity (so the identity guard passes) but neither one's
|
||||
// sku/spec actually satisfies the record's stall/article requirement.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "")
|
||||
_, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "重复候选的档口及货号不一致,不能自动逐件分配" {
|
||||
t.Fatalf("expected stall-mismatch rejection, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) {
|
||||
invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" {
|
||||
t.Fatalf("expected rejection for non-positive detail ID")
|
||||
}
|
||||
dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" {
|
||||
t.Fatalf("expected rejection for duplicate detail IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) {
|
||||
// Both candidates share identical raw spec/sku/variationSku and both
|
||||
// individually satisfy the stall check; the guards must not block the
|
||||
// legitimate happy path.
|
||||
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
|
||||
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
|
||||
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
|
||||
if reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", reason)
|
||||
}
|
||||
if assigned[0].ID != 21 || assigned[1].ID != 20 {
|
||||
t.Fatalf("assigned=%+v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) {
|
||||
// End-to-end regression for #289: N=2 single-piece candidates already
|
||||
// carrying one previously bound code, with the bound detail's ID not
|
||||
// matching sequential/ID order relative to the target codes. The plan
|
||||
// must leave the already-correct binding untouched and only place the
|
||||
// missing code onto the still-blank detail.
|
||||
db := testDB(t)
|
||||
// SourceSKURaw is intentionally left blank: with the same source SKU on
|
||||
// both candidate rows the source-SKU path would reject as "duplicate"
|
||||
// before ever reaching the stall-based multi-item assignment being
|
||||
// regression-tested here.
|
||||
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
|
||||
records := []models.SYBInnerCodeRecord{record}
|
||||
jobID := createMatchJob(t, db, records)
|
||||
recordID := records[0].ID
|
||||
// Detail 20 (lower ID) already carries IC-2 (bound out of sequence);
|
||||
// detail 21 (higher ID) is still blank and should receive IC-1.
|
||||
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{
|
||||
detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"),
|
||||
detail(21, "黑色,L", 1, "SKU-1", "A#1", ""),
|
||||
}}
|
||||
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
|
||||
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var plan models.SYBInnerCodePlan
|
||||
if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var items []plannedRemoteItem
|
||||
if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items=%+v", items)
|
||||
}
|
||||
byCode := map[string]plannedRemoteItem{}
|
||||
for _, item := range items {
|
||||
byCode[item.Code] = item
|
||||
}
|
||||
if byCode["IC-2"].DetailID != 20 {
|
||||
t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"])
|
||||
}
|
||||
if byCode["IC-1"].DetailID != 21 {
|
||||
t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"])
|
||||
}
|
||||
}
|
||||
|
||||
type captureStarter struct {
|
||||
jobID string
|
||||
committed bool
|
||||
|
||||
@@ -200,8 +200,8 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("部分记录不存在")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有读取失败或匹配受限记录可以重新匹配")
|
||||
if record.Status != models.SYBInnerCodePending && record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
|
||||
return conflict("只有待匹配、读取失败或匹配受限记录可以重新匹配")
|
||||
}
|
||||
if date == "" {
|
||||
date = record.BusinessDate
|
||||
@@ -209,10 +209,29 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
|
||||
return conflict("重新匹配记录必须属于同一营业日期")
|
||||
}
|
||||
}
|
||||
var activeJobs []models.SYBInnerCodeMatchJob
|
||||
if err := tx.Where("business_date = ? AND status IN ?", date, []string{"pending", "running"}).Find(&activeJobs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
selected := make(map[uint64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
selected[id] = true
|
||||
}
|
||||
for _, job := range activeJobs {
|
||||
var jobIDs []uint64
|
||||
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &jobIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range jobIDs {
|
||||
if selected[id] {
|
||||
return conflict("选中记录已有匹配任务,请等待任务结束")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodePending, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodeMatching, "result_message": "等待重新匹配"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
recordIDsJSON, _ := json.Marshal(ids)
|
||||
@@ -267,13 +286,13 @@ func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteReques
|
||||
return conflict("部分记录不存在,未删除任何数据")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
if record.Status == models.SYBInnerCodeMatching || record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
|
||||
result.Blocked = append(result.Blocked, BlockedRecord{ID: record.ID, Status: record.Status})
|
||||
}
|
||||
}
|
||||
if len(result.Blocked) > 0 {
|
||||
sort.Slice(result.Blocked, func(i, j int) bool { return result.Blocked[i].ID < result.Blocked[j].ID })
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
return &ServiceError{Code: CodeConflict, Message: "选中记录包含匹配中、排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
|
||||
}
|
||||
// The state gate above is the dynamic restriction for active writeback
|
||||
// evidence. Terminal evidence belongs to imported data and is physically
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package yeeke
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/yeekeclient"
|
||||
)
|
||||
|
||||
func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) {
|
||||
db := testDB(t)
|
||||
phase := 0
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Page int `json:"pageNo"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if phase == 0 && req.Status == "2" {
|
||||
fmt.Fprint(w, page(nil, 0, 0))
|
||||
return
|
||||
}
|
||||
if req.Status == "1" {
|
||||
if req.Page == 1 {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2))
|
||||
} else {
|
||||
fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original models.YeekeReturnPackage
|
||||
db.Where("external_id = ?", "p1").First(&original)
|
||||
phase = 1
|
||||
calls = nil
|
||||
for run := 0; run < 2; run++ {
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v", rep, err)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" {
|
||||
t.Fatalf("independent pagination: %v", calls)
|
||||
}
|
||||
var current models.YeekeReturnPackage
|
||||
db.First(¤t, original.ID)
|
||||
if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" {
|
||||
t.Fatalf("current=%+v", current)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.YeekeReturnPackage{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("packages=%d", count)
|
||||
}
|
||||
db.Model(&models.YeekeReturnItem{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("items=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) {
|
||||
db := testDB(t)
|
||||
old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"}
|
||||
if err := db.Create(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Status == "2" {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 10})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err == nil || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v", rep, err)
|
||||
}
|
||||
db.First(&old, old.ID)
|
||||
if old.SyncStatus != "ok" {
|
||||
t.Fatal("incomplete combined sync marked reshipped package missing")
|
||||
}
|
||||
var run models.YeekeSyncRun
|
||||
db.First(&run, rep.RunID)
|
||||
if run.ErrorMessage == "" {
|
||||
t.Fatal("missing state/page failure diagnostic")
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,8 @@ type Report struct {
|
||||
}
|
||||
|
||||
// knownClaimStatuses lists the status values the sync code currently
|
||||
// understands. The list surface (POST .../relation/list) is queried with
|
||||
// status=1, so "1" is the only value observed in practice; anything else is
|
||||
// flagged rather than silently accepted or rejected (#336).
|
||||
var knownClaimStatuses = map[string]bool{"1": true}
|
||||
// understands: waiting to ship (1) and reshipped (2), confirmed by HAR.
|
||||
var knownClaimStatuses = map[string]bool{"1": true, "2": true}
|
||||
|
||||
func external(v any) string { return fmt.Sprint(v) }
|
||||
func stamp(t *yeekeclient.Timestamp) *time.Time {
|
||||
@@ -194,86 +192,103 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
}
|
||||
s.db.Model(r).Updates(updates)
|
||||
}()
|
||||
seen := map[string]bool{}
|
||||
var firstWriteErr error
|
||||
seenPackages := map[string]string{}
|
||||
// complete tracks whether the page walk ended NATURALLY (empty page,
|
||||
// short page, or reaching p.Pages) as opposed to the duplicate-
|
||||
// fingerprint break or MaxPages exhaustion (#338): only a naturally
|
||||
// complete run is trusted to mark absent items/packages "missing" below,
|
||||
// since a duplicate/MaxPages stop means the walk never actually finished
|
||||
// seeing everything yeeke currently has.
|
||||
complete := false
|
||||
for page := 1; page <= s.cfg.MaxPages; page++ {
|
||||
var p yeekeclient.ReturnPage
|
||||
var e error
|
||||
for a := 0; ; a++ {
|
||||
p, e = s.client.List(ctx, page, s.cfg.PageSize)
|
||||
if e == nil || a >= s.cfg.Retry {
|
||||
complete := true
|
||||
for _, status := range []string{"1", "2"} {
|
||||
seen := map[string]bool{}
|
||||
statusComplete := false
|
||||
for page := 1; page <= s.cfg.MaxPages; page++ {
|
||||
var p yeekeclient.ReturnPage
|
||||
var e error
|
||||
for a := 0; ; a++ {
|
||||
p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status)
|
||||
if e == nil || a >= s.cfg.Retry {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
runErr = ctx.Err()
|
||||
errMsg = truncateRunError(runErr.Error())
|
||||
return rep, runErr
|
||||
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
// A failed page never overwrites what earlier pages already wrote
|
||||
// (#336): the run simply stops here and everything upserted so far
|
||||
// stays as-is, reported through Read/Created/Updated above.
|
||||
runErr = e
|
||||
errMsg = truncateRunError(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e))
|
||||
return rep, runErr
|
||||
}
|
||||
rep.TotalPages++
|
||||
if len(p.Records) == 0 {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
runErr = ctx.Err()
|
||||
errMsg = truncateRunError(runErr.Error())
|
||||
return rep, runErr
|
||||
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
|
||||
finger := pageFingerprint(p)
|
||||
if seen[finger] {
|
||||
rep.Skipped += len(p.Records)
|
||||
break
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
// A failed page never overwrites what earlier pages already wrote
|
||||
// (#336): the run simply stops here and everything upserted so far
|
||||
// stays as-is, reported through Read/Created/Updated above.
|
||||
runErr = e
|
||||
errMsg = truncateRunError(e.Error())
|
||||
return rep, runErr
|
||||
}
|
||||
rep.TotalPages = page
|
||||
if len(p.Records) == 0 {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
finger := pageFingerprint(p)
|
||||
if seen[finger] {
|
||||
rep.Skipped += len(p.Records)
|
||||
break
|
||||
}
|
||||
seen[finger] = true
|
||||
for _, x := range p.Records {
|
||||
created, updated, recovered, err := s.upsert(ctx, x)
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
seen[finger] = true
|
||||
for _, x := range p.Records {
|
||||
key, currentStatus := packageKey(x), external(x.Status)
|
||||
if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) {
|
||||
rep.Skipped++
|
||||
continue
|
||||
}
|
||||
created, updated, recovered, err := s.upsert(ctx, x)
|
||||
seenPackages[key] = currentStatus
|
||||
if err != nil {
|
||||
rep.Failed++
|
||||
if firstWriteErr == nil {
|
||||
firstWriteErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
rep.Recovered += recovered
|
||||
if created {
|
||||
rep.Created++
|
||||
} else if updated {
|
||||
rep.Updated++
|
||||
} else {
|
||||
rep.Skipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
rep.Read++
|
||||
rep.Recovered += recovered
|
||||
if created {
|
||||
rep.Created++
|
||||
} else if updated {
|
||||
rep.Updated++
|
||||
} else {
|
||||
rep.Skipped++
|
||||
if len(p.Records) < s.cfg.PageSize {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
if p.Pages > 0 && page >= p.Pages {
|
||||
statusComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(p.Records) < s.cfg.PageSize {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
if p.Pages > 0 && page >= p.Pages {
|
||||
complete = true
|
||||
if !statusComplete {
|
||||
complete = false
|
||||
errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
runErr = errors.New(errMsg)
|
||||
return rep, runErr
|
||||
}
|
||||
rep.Status = "succeeded"
|
||||
if rep.Failed > 0 && firstWriteErr != nil {
|
||||
// Surface why records failed instead of a bare counter.
|
||||
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
|
||||
if rep.Read == 0 {
|
||||
rep.Status = "failed"
|
||||
runErr = errors.New(errMsg)
|
||||
}
|
||||
rep.Status = "failed"
|
||||
runErr = errors.New(errMsg)
|
||||
}
|
||||
// #338: only a naturally complete run with zero write failures is
|
||||
// trusted to mark items/packages the sync no longer sees as "missing".
|
||||
@@ -288,7 +303,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro
|
||||
rep.MissingMarked = marked
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
return rep, runErr
|
||||
}
|
||||
|
||||
// markMissing implements #338's completion-triggered availability flip: any
|
||||
|
||||
@@ -204,11 +204,8 @@ func TestDuplicateFingerprintStopsMarking(t *testing.T) {
|
||||
defer srv2.Close()
|
||||
s.client, _ = yeekeclient.New(srv2.URL)
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
if rep.MissingMarked != 0 {
|
||||
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
|
||||
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err)
|
||||
}
|
||||
|
||||
var p2 models.YeekeReturnPackage
|
||||
@@ -250,11 +247,8 @@ func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
|
||||
s.client, _ = yeekeclient.New(srv2.URL)
|
||||
s.cfg.MaxPages = 2
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
if rep.MissingMarked != 0 {
|
||||
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
|
||||
if err == nil || rep.Status != "failed" || rep.MissingMarked != 0 {
|
||||
t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err)
|
||||
}
|
||||
|
||||
var p2 models.YeekeReturnPackage
|
||||
|
||||
@@ -108,10 +108,7 @@ func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
|
||||
c, _ := yeekeclient.New(srv.URL)
|
||||
s := NewService(db, c, Config{PageSize: 1})
|
||||
rep, err := s.Sync(context.Background(), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" {
|
||||
if err == nil || rep.Status != "failed" {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
var n int64
|
||||
@@ -138,7 +135,7 @@ func TestPagingStopsOnEmptyPage(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
|
||||
if rep.Status != "succeeded" || rep.TotalPages != 2 || rep.Read != 0 {
|
||||
t.Fatalf("rep=%+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +371,16 @@ func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
|
||||
return c.ListStatus(ctx, pageNo, pageSize, "1")
|
||||
}
|
||||
|
||||
// ListStatus reads only the two HAR-confirmed return statuses.
|
||||
func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) {
|
||||
if status != "1" && status != "2" {
|
||||
return ReturnPage{}, fmt.Errorf("unsupported yeeke return status")
|
||||
}
|
||||
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"}
|
||||
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": status, "relationFlag": "1", "column": "createTime", "order": "desc"}
|
||||
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
|
||||
if e != nil {
|
||||
return ReturnPage{}, e
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package yeekeclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListStatusUsesConfirmedHARFilters(t *testing.T) {
|
||||
var statuses []string
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" {
|
||||
t.Error("HAR filters changed")
|
||||
}
|
||||
statuses = append(statuses, body["status"].(string))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`))
|
||||
}))
|
||||
defer s.Close()
|
||||
c, _ := New(s.URL)
|
||||
for _, status := range []string{"1", "2"} {
|
||||
if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil {
|
||||
t.Fatal("unsupported status allowed")
|
||||
}
|
||||
if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" {
|
||||
t.Fatal(statuses)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,9 @@ func run() error {
|
||||
if err := goautosybinnercode.RecoverInterrupted(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
|
||||
}
|
||||
if err := goautosybinnercode.RecoverInterruptedMatches(db); err != nil {
|
||||
return fmt.Errorf("recover interrupted SYB inner-code matches: %w", err)
|
||||
}
|
||||
goautoreplacement.RecoverMatching(db)
|
||||
goautopurchase.RecoverPurchaseMatching(db)
|
||||
goautopurchase.RecoverOrderWritebacks(db)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
|
||||
|
||||
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
|
||||
}
|
||||
|
||||
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
|
||||
// copies the current purchaser menu/API grants, so the two roles stay aligned
|
||||
// for the current product surface (including yeeke 退货同步 and 退货商品).
|
||||
// The initial password is deliberately supplied only at migration time via an
|
||||
// environment variable; it is never stored in source, logs, or issue text.
|
||||
func migrateAfterSalesRole(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
role, err := ensureAfterSalesRole(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
|
||||
}
|
||||
|
||||
role := migrationmodels.SysRole{}
|
||||
if err := db.Where("role_key = ?", access.RoleAfterSales).
|
||||
Assign(migrationmodels.SysRole{
|
||||
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
|
||||
Admin: false, DataScope: purchaser.DataScope,
|
||||
Remark: "GoAuto 售后业务角色(系统维护)",
|
||||
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT ?, source.menu_id
|
||||
FROM sys_role_menu AS source
|
||||
WHERE source.role_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu AS target
|
||||
WHERE target.role_id = ? AND target.menu_id = source.menu_id
|
||||
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser menus: %w", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
|
||||
FROM casbin_rule AS source
|
||||
WHERE source.ptype = 'p' AND source.v0 = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM casbin_rule AS target
|
||||
WHERE target.ptype = source.ptype AND target.v0 = ?
|
||||
AND target.v1 = source.v1 AND target.v2 = source.v2
|
||||
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
|
||||
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
|
||||
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
|
||||
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser API policies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
|
||||
missing := make([]string, 0, len(afterSalesUsers))
|
||||
for _, username := range afterSalesUsers {
|
||||
var existing adminmodels.SysUser
|
||||
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
missing = append(missing, username)
|
||||
case err != nil:
|
||||
return fmt.Errorf("find after-sales user %q: %w", username, err)
|
||||
case existing.RoleId != roleID:
|
||||
return fmt.Errorf("after-sales user %q already exists with another role", username)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
|
||||
if password == "" {
|
||||
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
|
||||
}
|
||||
// Validate before creating any account; the value itself is never logged or
|
||||
// written to a repository artifact.
|
||||
if len(password) < 1 {
|
||||
return errors.New("after-sales initial password is empty")
|
||||
}
|
||||
for _, username := range missing {
|
||||
user := adminmodels.SysUser{
|
||||
Username: username, Password: password, NickName: username,
|
||||
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("create after-sales user %q: %w", username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
|
||||
if err = db.Create(&purchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
|
||||
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
|
||||
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err := ensureAfterSalesRole(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != int64(len(afterSalesUsers)) {
|
||||
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
|
||||
}
|
||||
var user adminmodels.SysUser
|
||||
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
|
||||
t.Fatal("initial password was not stored as a bcrypt hash")
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d after-sales policies, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #341 follow-up: the after-sales role was created before #338 added the
|
||||
// return-match API surface. Add only the reviewed return-match grants so the
|
||||
// existing role can use the new SYB action without recreating or changing its
|
||||
// users.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
|
||||
}
|
||||
|
||||
type afterSalesReturnMatchAPI struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Title string `gorm:"column:title;size:128"`
|
||||
Path string `gorm:"column:path;size:128"`
|
||||
Type string `gorm:"column:type;size:16"`
|
||||
Action string `gorm:"column:action;size:16"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
|
||||
|
||||
type afterSalesReturnMatchPolicy struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"column:ptype;size:100"`
|
||||
V0 string `gorm:"column:v0;size:100"`
|
||||
V1 string `gorm:"column:v1;size:100"`
|
||||
V2 string `gorm:"column:v2;size:100"`
|
||||
V3 string `gorm:"column:v3;size:100"`
|
||||
V4 string `gorm:"column:v4;size:100"`
|
||||
V5 string `gorm:"column:v5;size:100"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
|
||||
|
||||
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var role struct {
|
||||
RoleID int `gorm:"column:role_id"`
|
||||
}
|
||||
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
|
||||
return fmt.Errorf("find after-sales role: %w", err)
|
||||
}
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
|
||||
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
|
||||
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
|
||||
FirstOrCreate(&api).Error; err != nil {
|
||||
return fmt.Errorf("ensure return-match API: %w", err)
|
||||
}
|
||||
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
|
||||
var count int64
|
||||
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := tx.Create(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAfterSalesReturnMatchCatalogAndGrants(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &afterSalesReturnMatchAPI{}, &afterSalesReturnMatchPolicy{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"test-first", "test-repeat"} {
|
||||
if err = migrateAfterSalesReturnMatch(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var apis []afterSalesReturnMatchAPI
|
||||
if err = db.Find(&apis).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apis) != 8 {
|
||||
t.Fatalf("API count=%d", len(apis))
|
||||
}
|
||||
for _, api := range apis {
|
||||
if api.Path == "" || api.Action == "" {
|
||||
t.Fatalf("empty API path/action: id=%d", api.ID)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&afterSalesReturnMatchPolicy{}).Where("v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 {
|
||||
t.Fatalf("policy count=%d", count)
|
||||
}
|
||||
// Simulate the original deployed catalogue bug, then repair and repeat.
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("id > 0").Updates(map[string]any{"path": "", "action": ""}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"repair-first", "repair-repeat"} {
|
||||
if err = migrateFixReturnMatchAPICatalog(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("path = '' OR action = ''").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unrepaired API rows=%d", count)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Repair the API catalogue rows created by 1789801200000 before the path and
|
||||
// action fields were populated. This is idempotent and only touches the
|
||||
// return-match endpoints.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
|
||||
}
|
||||
|
||||
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
|
||||
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBSessionAuthLease)
|
||||
}
|
||||
|
||||
// The lease is additive and contains no credentials. It serializes automatic
|
||||
// SYB re-authentication across sync and purchase writeback workers.
|
||||
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -12,12 +12,12 @@ export function batchMatchReturns(data) {
|
||||
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
|
||||
}
|
||||
|
||||
export function confirmReturnMatch(matchId) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
|
||||
export function confirmReturnMatch(matchId, options = {}) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post', ...options })
|
||||
}
|
||||
|
||||
export function cancelReturnMatch(matchId) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
|
||||
export function cancelReturnMatch(matchId, data, options = {}) {
|
||||
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post', data, ...options })
|
||||
}
|
||||
|
||||
export function remarkReturnMatch(matchId, data) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<div class="page-heading"><div><h1>档口入库码</h1><p>导入 Excel 后自动匹配 SYB 商品;确认后逐件回写,结果不明确时只读复核。</p></div></div>
|
||||
<div class="toolbar" role="search" aria-label="档口入库码筛选与批量操作">
|
||||
<el-upload ref="upload" :auto-upload="false" :limit="1" accept=".xlsx" :show-file-list="false" :on-change="selectFile" :on-exceed="replaceFile"><el-button :icon="FolderOpened">选择 Excel</el-button></el-upload>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing" @click="submitImport">导入</el-button>
|
||||
<el-button type="primary" :icon="Upload" :loading="importing" :disabled="!selectedFile || importing || matchingBusy" @click="submitImport">导入</el-button>
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" />
|
||||
<el-input v-model="query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" />
|
||||
<el-button :icon="RefreshLeft" @click="clearFilters">清除</el-button>
|
||||
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
|
||||
<span class="toolbar-spacer" />
|
||||
<el-button :loading="matchSubmitting" :disabled="!matchEligibleCount || loading || !!loadError || importing || busy || matchingBusy || !!rowActionId" @click="matchSelected">匹配 {{ matchEligibleCount }}</el-button>
|
||||
<el-button type="primary" :disabled="selected.length === 0 || busy" @click="openApply">回写({{ applyEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
<el-button type="danger" plain :disabled="selected.length === 0 || busy || matchingBusy" @click="openDelete">删除({{ deleteEligibleCount }})</el-button>
|
||||
</div>
|
||||
<div class="file-state" aria-live="polite"><template v-if="selectedFile">已选择:<strong>{{ selectedFile.name }}</strong>({{ fileSize(selectedFile.size) }})</template><template v-else>请选择“标签入库码映射”Excel 文件,最大 10MB。</template></div>
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
<el-table-column label="本地规格" prop="specRaw" min-width="170" />
|
||||
<el-table-column label="SYB 规格" min-width="170"><template #default="{ row }"><span v-if="row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted">{{ sybSpecPlaceholder(row.status) }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="180"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="row.resultMessage" class="status-note">{{ row.resultMessage }}</div></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="['pending', 'failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" :disabled="matchingBusy || busy || importing || loading || !!rowActionId" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)">只读复核</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
@@ -66,10 +67,13 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
data() { return { loading: false, importing: false, busy: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: null, rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: '', dateTo: '', keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
computed: {
|
||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
||||
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
|
||||
matchEligibleCount() { return this.matchEligibleRows.length },
|
||||
applyEligibleCount() { return this.selected.filter(item => item.status === 'ready').length },
|
||||
deleteBlocked() { return this.selected.filter(item => ['queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
deleteBlocked() { return this.selected.filter(item => ['matching', 'queued', 'applying', 'needs_check'].includes(item.status)) },
|
||||
deleteEligibleCount() { return this.deleteBlocked.length ? 0 : this.selected.length },
|
||||
jobPercent() { return this.activeJob?.total ? Math.round(this.activeJob.processed * 100 / this.activeJob.total) : 0 },
|
||||
jobTitle() { const job = this.activeJob; return job?.status === 'failed' ? '自动匹配失败' : `自动匹配:${job?.processed || 0}/${job?.total || 0},可回写 ${job?.ready || 0},异常 ${job?.failed || 0}` },
|
||||
@@ -89,7 +93,19 @@ export default {
|
||||
async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } },
|
||||
openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } },
|
||||
async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } },
|
||||
async matchSelected() { await this.startMatch(this.matchEligibleRows) },
|
||||
async rematch(row) { if (this.matchingBusy || this.rowActionId) return; this.rowActionId = row.id; try { await this.startMatch([row]) } finally { this.rowActionId = 0 } },
|
||||
async startMatch(rows) {
|
||||
if (!rows.length || this.matchingBusy || this.busy || this.importing || this.loading || this.loadError) return
|
||||
if (new Set(rows.map(row => row.businessDate)).size !== 1) { ElMessage.warning('请按同一营业日期分别匹配'); return }
|
||||
this.matchSubmitting = true
|
||||
try {
|
||||
const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [...new Set(rows.map(row => row.id))] })
|
||||
this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.queued, processed: 0, ready: 0, failed: 0 }
|
||||
ElMessage.success(`已开始匹配 ${r.data.queued} 条记录;成功后请另行确认回写`)
|
||||
this.startPolling(); await this.load()
|
||||
} finally { this.matchSubmitting = false }
|
||||
},
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } },
|
||||
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null },
|
||||
async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() }
|
||||
|
||||
@@ -3,16 +3,21 @@
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></el-form-item>
|
||||
<div class="search-row search-row-primary">
|
||||
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
|
||||
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || matchDetail.saving || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnCancelButtonReason" :disabled="!returnCancelButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnCancelBatchLoading" :disabled="Boolean(returnCancelButtonReason)" @click="runBatchCancelReturns">取消匹配<span class="action-count">{{ returnCancelCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnConfirmButtonReason" :disabled="!returnConfirmButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :loading="returnConfirmBatchLoading" :disabled="Boolean(returnConfirmButtonReason)" @click="runBatchConfirmReturns">使用退货<span class="action-count">{{ returnConfirmCandidates.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</div>
|
||||
<div class="search-row search-row-secondary">
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
@@ -67,7 +72,7 @@
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||
<el-button v-if="canPurchase" type="danger" link :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="muted">未匹配</span>
|
||||
@@ -75,7 +80,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
@@ -185,6 +190,44 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 匹配退货批量结果 -->
|
||||
<el-dialog v-model="returnCancelResult.open" :title="returnCancelBatchLoading ? '正在取消退货匹配' : '批量取消匹配结果'" width="820px" :close-on-click-modal="false" :close-on-press-escape="!returnCancelBatchLoading" :show-close="!returnCancelBatchLoading">
|
||||
<div aria-live="polite" class="result-summary">
|
||||
<span>已处理 {{ returnCancelResult.processed }} / {{ returnCancelResult.total }} 条</span>
|
||||
<span class="success-text">成功 {{ returnCancelResult.successCount }} 条</span>
|
||||
<span>跳过 {{ returnCancelResult.skippedCount }} 条</span>
|
||||
<span class="danger-text">失败 {{ returnCancelResult.failedCount }} 条</span>
|
||||
</div>
|
||||
<el-progress v-if="returnCancelBatchLoading" :percentage="returnCancelResult.total ? Math.round(returnCancelResult.processed / returnCancelResult.total * 100) : 0" />
|
||||
<el-alert v-if="returnCancelResult.refreshError" :title="returnCancelResult.refreshError" type="warning" :closable="false" show-icon />
|
||||
<p class="muted">仅取消原退货匹配,不修改已有采集或采购任务。失败项仅在当前页仍可取消时保留勾选;不在当前页的失败项仍在下表中。</p>
|
||||
<el-table :data="returnCancelResult.items" border size="small" max-height="420" empty-text="正在处理,请稍候">
|
||||
<el-table-column label="SYB 订单" prop="orderCode" min-width="180" />
|
||||
<el-table-column label="原匹配编号" prop="matchId" width="100" />
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }">{{ { success: '成功', skipped: '跳过', failed: '失败' }[row.status] }}</template></el-table-column>
|
||||
<el-table-column label="说明" prop="reason" min-width="280" />
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" :disabled="returnCancelBatchLoading" @click="returnCancelResult.open = false">返回列表</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="returnConfirmResult.open" :title="returnConfirmBatchLoading ? '正在使用退货' : '批量使用退货结果'" width="820px" :close-on-click-modal="false" :close-on-press-escape="!returnConfirmBatchLoading" :show-close="!returnConfirmBatchLoading">
|
||||
<div aria-live="polite" class="result-summary">
|
||||
<span>已处理 {{ returnConfirmResult.processed }} / {{ returnConfirmResult.total }} 条</span>
|
||||
<span class="success-text">成功 {{ returnConfirmResult.successCount }} 条</span>
|
||||
<span>跳过 {{ returnConfirmResult.skippedCount }} 条</span>
|
||||
<span class="danger-text">失败 {{ returnConfirmResult.failedCount }} 条</span>
|
||||
</div>
|
||||
<el-progress v-if="returnConfirmBatchLoading" :percentage="returnConfirmResult.total ? Math.round(returnConfirmResult.processed / returnConfirmResult.total * 100) : 0" />
|
||||
<el-alert v-if="returnConfirmResult.refreshError" :title="returnConfirmResult.refreshError" type="warning" :closable="false" show-icon />
|
||||
<p class="muted">仅标记已用退货,不修改已有采购任务,也不会自动让 Yeeke 发货。失败项仅在当前页仍为原待确认匹配时保留勾选;离页失败项仍保留在下表。</p>
|
||||
<el-table :data="returnConfirmResult.items" border size="small" max-height="420" empty-text="正在处理,请稍候">
|
||||
<el-table-column label="SYB 订单" prop="orderCode" min-width="180" />
|
||||
<el-table-column label="原匹配编号" prop="matchId" width="100" />
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }">{{ { success: '成功', skipped: '跳过', failed: '失败' }[row.status] }}</template></el-table-column>
|
||||
<el-table-column label="说明" prop="reason" min-width="280" />
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" :disabled="returnConfirmBatchLoading" @click="returnConfirmResult.open = false">返回列表</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
|
||||
<div class="purchase-summary" aria-live="polite">
|
||||
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
|
||||
@@ -209,7 +252,7 @@
|
||||
<div class="split">
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">SYB 商品</h3>
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
|
||||
<el-descriptions v-if="matchDetail.data.syb" :column="1" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
|
||||
@@ -225,7 +268,7 @@
|
||||
<h3 class="section-title">yeeke 退货商品</h3>
|
||||
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||
<el-descriptions v-if="matchDetail.data.yeeke" :column="1" border size="small">
|
||||
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
|
||||
@@ -242,7 +285,7 @@
|
||||
<h3 class="section-title">备注</h3>
|
||||
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
|
||||
<div class="drawer-actions">
|
||||
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
|
||||
<el-button v-if="canPurchase" :loading="matchDetail.saving" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading" @click="saveMatchRemark">保存备注</el-button>
|
||||
</div>
|
||||
<h3 class="section-title">操作日志</h3>
|
||||
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
|
||||
@@ -255,8 +298,8 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="matchDetail.open = false">关闭</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="confirmMatchDetail">确认</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" :disabled="returnConfirmBatchLoading || returnCancelBatchLoading || returnTransitionLoading || returnMatchBatchLoading || matchDetail.saving" @click="cancelMatchDetail">取消匹配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -319,6 +362,8 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import quickLink from './quick-link'
|
||||
import returnCancel from './return-cancel'
|
||||
import returnConfirm from './return-confirm'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
@@ -333,11 +378,17 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
|
||||
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
|
||||
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
|
||||
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
|
||||
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
|
||||
const PURCHASE_BATCH_MAX = 100
|
||||
const COLLECTION_BATCH_MAX = 100
|
||||
const RETURN_MATCH_BATCH_MAX = 500
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
|
||||
mixins: [quickLink],
|
||||
mixins: [quickLink, returnCancel, returnConfirm],
|
||||
setup() { return { Search, RefreshLeft, ArrowDown } },
|
||||
data() {
|
||||
return {
|
||||
@@ -353,7 +404,7 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -368,24 +419,38 @@ export default {
|
||||
computed: {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
aiMatchButtonReason() {
|
||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
||||
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
|
||||
if (this.aiMatchCandidates.length) return ''
|
||||
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
||||
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
||||
},
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
|
||||
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
|
||||
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
|
||||
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
|
||||
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => this.isImageSearchCandidate(row)) },
|
||||
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
|
||||
// 这里用相同口径预估,避免提交后才发现超限。
|
||||
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
|
||||
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
|
||||
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
|
||||
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
|
||||
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchBatchDurationText() {
|
||||
@@ -403,7 +468,7 @@ export default {
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
async load(allowNetworkRetry = true) {
|
||||
async load(allowNetworkRetry = true, waitForReadiness = false) {
|
||||
const orderCodes = this.normalizedOrderCodes()
|
||||
if (orderCodes === null) return
|
||||
const generation = ++this.loadGeneration
|
||||
@@ -412,13 +477,17 @@ export default {
|
||||
this.$refs.productTable?.clearSelection()
|
||||
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
|
||||
try {
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
|
||||
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
|
||||
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
this.loading = false
|
||||
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation)
|
||||
void this.loadReturnMatches(this.products.map(item => item.id), generation)
|
||||
const pending = Promise.all([
|
||||
this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation),
|
||||
this.loadReturnMatches(this.products.map(item => item.id), generation)
|
||||
])
|
||||
if (waitForReadiness) await pending
|
||||
} catch (error) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
|
||||
@@ -432,7 +501,7 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -456,13 +525,13 @@ export default {
|
||||
return values
|
||||
},
|
||||
handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' },
|
||||
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
|
||||
isAIMatchCandidate(row) { return this.purchaseReady(row).aiMatchEligible === true },
|
||||
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.eligible === true && ready.processStage === 'purchase_ready' },
|
||||
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return !this.isActivelyReturnMatched(row) && ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
|
||||
isAIMatchCandidate(row) { return !this.isActivelyReturnMatched(row) && this.purchaseReady(row).aiMatchEligible === true },
|
||||
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
|
||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isReturnMatchCandidate(row) {
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
|
||||
@@ -471,23 +540,37 @@ export default {
|
||||
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
|
||||
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
|
||||
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
|
||||
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
|
||||
isSelectableCandidate(row) { return this.purchaseReady(row).processStage === 'return_pending' || this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
|
||||
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
// caps a single request at 100 ids, so this splits the current page into
|
||||
// sequential chunks of <=100 and merges their results. Any chunk failing
|
||||
// falls back to the same page-wide failure state the single-request path
|
||||
// used before, so rows never end up in a partially-updated mix of real
|
||||
// and failed readiness.
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
||||
this.purchaseReadiness = {}
|
||||
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
||||
this.purchaseReadinessLoading = true
|
||||
const chunkSize = 100
|
||||
const merged = {}
|
||||
try {
|
||||
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
|
||||
for (let start = 0; start < ids.length; start += chunkSize) {
|
||||
const chunk = ids.slice(start, start + chunkSize)
|
||||
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
for (const item of r.data.items) merged[item.sybProductId] = item
|
||||
}
|
||||
this.purchaseReadiness = merged
|
||||
await this.$nextTick()
|
||||
if (generation !== this.loadGeneration) return
|
||||
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
||||
this.$refs.productTable?.clearSelection()
|
||||
valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
@@ -523,7 +606,7 @@ export default {
|
||||
|
||||
async runBatchSpecMatch() {
|
||||
if (!this.aiMatchCandidates.length || this.specMatchLoading) return
|
||||
const selectedIDs = this.selectedProducts.map(row => row.id)
|
||||
const selectedIDs = this.selectedProducts.filter(row => !this.isActivelyReturnMatched(row)).map(row => row.id)
|
||||
this.specMatchLoading = true
|
||||
try {
|
||||
const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs })
|
||||
@@ -546,8 +629,10 @@ export default {
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
|
||||
async loadReturnMatches(ids, generation = this.loadGeneration) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.returnMatchByProductId = {}
|
||||
if (!ids.length) return
|
||||
this.returnMatchLoadError = false
|
||||
if (!ids.length) { this.returnMatchLoading = false; return }
|
||||
this.returnMatchLoading = true
|
||||
try {
|
||||
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
|
||||
@@ -559,7 +644,7 @@ export default {
|
||||
}
|
||||
this.returnMatchByProductId = map
|
||||
} catch {
|
||||
// 展示失败不影响主列表;用户可重新查询重试。
|
||||
if (generation === this.loadGeneration) this.returnMatchLoadError = true
|
||||
} finally {
|
||||
if (generation === this.loadGeneration) this.returnMatchLoading = false
|
||||
}
|
||||
@@ -573,7 +658,7 @@ export default {
|
||||
},
|
||||
async runBatchMatchReturns() {
|
||||
const ids = this.returnMatchCandidateIds
|
||||
if (!ids.length || this.returnMatchBatchLoading) return
|
||||
if (!ids.length || this.returnMatchBatchLoading || this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
this.returnMatchBatchLoading = true
|
||||
try {
|
||||
const r = await batchMatchReturns({ sybProductIds: ids })
|
||||
@@ -608,6 +693,7 @@ export default {
|
||||
await this.load()
|
||||
},
|
||||
async saveMatchRemark() {
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnTransitionLoading || this.returnMatchBatchLoading || this.matchDetail.saving) return
|
||||
this.matchDetail.saving = true
|
||||
try {
|
||||
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
|
||||
@@ -618,29 +704,35 @@ export default {
|
||||
}
|
||||
},
|
||||
async confirmMatchDetail() {
|
||||
try {
|
||||
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
|
||||
} catch { return }
|
||||
await confirmReturnMatch(this.matchDetail.id)
|
||||
ElMessage.success('已确认匹配')
|
||||
await this.refreshMatchDetail()
|
||||
return this.runReturnTransition(async() => {
|
||||
try {
|
||||
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
|
||||
} catch { return }
|
||||
await confirmReturnMatch(this.matchDetail.id)
|
||||
ElMessage.success('已确认匹配')
|
||||
await this.refreshMatchDetail()
|
||||
})
|
||||
},
|
||||
async cancelMatchDetail() {
|
||||
try {
|
||||
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||
} catch { return }
|
||||
await cancelReturnMatch(this.matchDetail.id)
|
||||
ElMessage.success('已取消匹配')
|
||||
this.matchDetail.open = false
|
||||
await this.load()
|
||||
return this.runReturnTransition(async() => {
|
||||
try {
|
||||
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||
} catch { return }
|
||||
await cancelReturnMatch(this.matchDetail.id)
|
||||
ElMessage.success('已取消匹配')
|
||||
this.matchDetail.open = false
|
||||
await this.load()
|
||||
})
|
||||
},
|
||||
async quickCancelMatch(matchId) {
|
||||
try {
|
||||
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||
} catch { return }
|
||||
await cancelReturnMatch(matchId)
|
||||
ElMessage.success('已取消匹配')
|
||||
await this.load()
|
||||
return this.runReturnTransition(async() => {
|
||||
try {
|
||||
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
|
||||
} catch { return }
|
||||
await cancelReturnMatch(matchId)
|
||||
ElMessage.success('已取消匹配')
|
||||
await this.load()
|
||||
})
|
||||
},
|
||||
|
||||
// ---------------- 批量创建采购任务 ----------------
|
||||
@@ -823,7 +915,9 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.page-card { min-height: calc(100vh - 124px); }
|
||||
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
||||
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-form{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-row{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px}
|
||||
.search-row-secondary{justify-content:flex-start}
|
||||
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
||||
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
||||
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
||||
@@ -855,7 +949,6 @@ export default {
|
||||
.section-title{margin:0 0 12px}
|
||||
.drawer-actions{display:flex;gap:12px;margin-top:16px}
|
||||
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:1280px){.search-row-primary{align-items:flex-start}.search-row-primary .toolbar-actions{margin-left:auto}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cancelReturnMatch, getReturnMatch } from '@/api/goauto/return-matches'
|
||||
|
||||
const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true }
|
||||
function isUnauthorized(error) {
|
||||
return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized'
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
returnCancelBatchLoading: false, returnTransitionLoading: false, returnMatchLoadError: false,
|
||||
returnCancelStopped: false,
|
||||
returnCancelResult: { open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
returnCancelCandidates() { return this.selectedProducts.filter(row => this.isReturnCancelCandidate(row)) },
|
||||
returnCancelButtonReason() {
|
||||
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
|
||||
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
|
||||
if (!this.returnCancelCandidates.length) return '请勾选退货待确认的已匹配商品;已用退货不参与批量取消'
|
||||
return ''
|
||||
}
|
||||
},
|
||||
beforeUnmount() { this.returnCancelStopped = true; this.loadGeneration++ },
|
||||
methods: {
|
||||
isReturnCancelCandidate(row) {
|
||||
const match = this.returnMatchByProductId[row.id]
|
||||
return this.purchaseReady(row).processStage === 'return_pending' && match?.status === 'matched' && Number(match.id) > 0
|
||||
},
|
||||
async runReturnTransition(action) {
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return
|
||||
this.returnTransitionLoading = true
|
||||
try { await action() } finally { this.returnTransitionLoading = false }
|
||||
},
|
||||
async runBatchCancelReturns() {
|
||||
if (!this.canPurchase || this.returnCancelButtonReason) return
|
||||
// Freeze both identities before the confirmation; never follow a later rematch.
|
||||
const seen = new Set()
|
||||
const snapshot = this.selectedProducts.filter(row => {
|
||||
if (seen.has(row.id)) return false
|
||||
seen.add(row.id); return true
|
||||
}).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) }))
|
||||
const count = snapshot.filter(item => item.eligible).length
|
||||
this.returnCancelBatchLoading = true
|
||||
try {
|
||||
try {
|
||||
await this.$confirm(`取消选中的 ${count} 条退货匹配?取消后商品将重新计算采购准备状态。`, '批量取消匹配', { type: 'warning', confirmButtonText: '确认取消匹配', cancelButtonText: '返回', confirmButtonClass: 'el-button--danger' })
|
||||
} catch { return }
|
||||
if (this.returnCancelStopped) return
|
||||
this.returnCancelResult = { open: true, items: [], total: snapshot.length, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' }
|
||||
const result = this.returnCancelResult
|
||||
let authExpired = false
|
||||
for (const original of snapshot) {
|
||||
const item = { ...original, status: 'skipped', reason: '', retryable: false }
|
||||
if (!original.eligible) item.reason = '非退货待确认的已匹配商品,跳过'
|
||||
else if (authExpired || this.returnCancelStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
|
||||
try {
|
||||
const response = await cancelReturnMatch(original.matchId, { expectedStatus: 'matched' }, requestOptions)
|
||||
// The shared interceptor returns false for the legacy 6401 login expiry.
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
item.status = 'success'; item.reason = '已取消匹配,重新计算采购准备状态'
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) {
|
||||
authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查结果'
|
||||
} else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') {
|
||||
item.reason = '原匹配状态已变化,未取消'
|
||||
} else {
|
||||
item.status = 'failed'
|
||||
item.reason = error?.response ? '取消失败,请刷新后检查原匹配' : '网络异常,取消结果未确认'
|
||||
// Read-only recovery; never repeat an uncertain POST automatically.
|
||||
try {
|
||||
const response = await getReturnMatch(original.matchId, requestOptions)
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
const match = response.data?.match
|
||||
if (match?.status === 'cancelled' || match?.status === 'confirmed') {
|
||||
item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'cancelled' ? '取消' : '确认'},未重复操作`
|
||||
} else {
|
||||
item.retryable = match?.status === 'matched' && Number(match.id) === Number(original.matchId) && Number(match.sybProductId) === Number(original.sybProductId)
|
||||
if (item.retryable) item.reason += ';原匹配仍待确认,可手动重试'
|
||||
}
|
||||
} catch (readError) {
|
||||
if (isUnauthorized(readError)) authExpired = true
|
||||
item.reason += ';回读失败,请刷新检查,不自动重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.items.push(item); result.processed++
|
||||
result[`${item.status}Count`]++
|
||||
}
|
||||
if (this.returnCancelStopped || authExpired) return
|
||||
const generation = this.loadGeneration + 1
|
||||
try {
|
||||
await this.load(false, true)
|
||||
if (generation !== this.loadGeneration || this.returnCancelStopped) return
|
||||
if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) {
|
||||
result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return
|
||||
}
|
||||
const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId]))
|
||||
await this.$nextTick()
|
||||
if (generation !== this.loadGeneration || this.returnCancelStopped) return
|
||||
this.$refs.productTable?.clearSelection()
|
||||
this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
} catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复取消' }
|
||||
} finally { this.returnCancelBatchLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { confirmReturnMatch, getReturnMatch } from '@/api/goauto/return-matches'
|
||||
|
||||
const requestOptions = { suppressErrorMessage: true, suppressNetworkError: true }
|
||||
function isUnauthorized(error) {
|
||||
return error?.response?.status === 401 || error?.response?.data?.code === 401 || error?.message === 'Unauthorized'
|
||||
}
|
||||
const emptyResult = () => ({ open: false, items: [], total: 0, processed: 0, successCount: 0, skippedCount: 0, failedCount: 0, refreshError: '' })
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { returnConfirmBatchLoading: false, returnConfirmStopped: false, returnConfirmResult: emptyResult() }
|
||||
},
|
||||
computed: {
|
||||
returnConfirmCandidates() { return this.returnConfirmSnapshot().filter(item => item.eligible) },
|
||||
returnConfirmButtonReason() {
|
||||
if (this.loading || this.purchaseReadinessLoading || this.returnMatchLoading) return '正在读取处理阶段与退货匹配'
|
||||
if (this.returnMatchLoadError) return '退货匹配读取失败,请刷新重试'
|
||||
if (this.returnConfirmBatchLoading || this.returnCancelBatchLoading || this.returnMatchBatchLoading || this.returnTransitionLoading || this.matchDetail.saving) return '正在处理退货匹配,请稍候'
|
||||
if (!this.returnConfirmCandidates.length) return '请勾选当前页退货待确认的已匹配商品;已用退货不参与批量使用'
|
||||
return ''
|
||||
}
|
||||
},
|
||||
beforeUnmount() { this.returnConfirmStopped = true },
|
||||
methods: {
|
||||
returnConfirmSnapshot() {
|
||||
const current = new Set(this.products.map(row => row.id))
|
||||
const seen = new Set()
|
||||
return this.selectedProducts.filter(row => {
|
||||
if (!current.has(row.id) || seen.has(row.id)) return false
|
||||
seen.add(row.id); return true
|
||||
}).map(row => ({ sybProductId: row.id, orderCode: row.orderCode, matchId: this.returnMatchByProductId[row.id]?.id, eligible: this.isReturnCancelCandidate(row) }))
|
||||
},
|
||||
async runBatchConfirmReturns() {
|
||||
if (!this.canPurchase || this.returnConfirmButtonReason) return
|
||||
// Freeze original identities before asking. Never follow a later replacement match.
|
||||
const snapshot = this.returnConfirmSnapshot()
|
||||
const count = snapshot.filter(item => item.eligible).length
|
||||
this.returnConfirmBatchLoading = true
|
||||
try {
|
||||
try {
|
||||
await this.$confirm(`确认使用选中的 ${count} 条退货商品?确认后将标记为已用退货,不再创建采购任务。不修改已有采购任务,也不会自动让 Yeeke 发货。`, '使用退货', { type: 'warning', confirmButtonText: '确认使用', cancelButtonText: '返回' })
|
||||
} catch { return }
|
||||
if (this.returnConfirmStopped) return
|
||||
this.returnConfirmResult = { ...emptyResult(), open: true, total: snapshot.length }
|
||||
const result = this.returnConfirmResult
|
||||
let authExpired = false
|
||||
for (const original of snapshot) {
|
||||
const item = { ...original, status: 'skipped', reason: '', retryable: false }
|
||||
if (!original.eligible) item.reason = '非退货待确认的原匹配,跳过'
|
||||
else if (authExpired || this.returnConfirmStopped) { item.status = 'failed'; item.reason = '登录失效或已离开页面,未继续执行' } else {
|
||||
try {
|
||||
const response = await confirmReturnMatch(original.matchId, requestOptions)
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
item.status = 'success'; item.reason = '已用退货;不修改已有采购任务,不自动发货'
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) {
|
||||
authExpired = true; item.status = 'failed'; item.reason = '登录失效,请重新登录后检查原匹配'
|
||||
} else if (error?.response?.status === 409 && error?.response?.data?.code === 'STATE_CONFLICT') {
|
||||
item.reason = '原匹配已确认或已取消,未重复使用'
|
||||
} else {
|
||||
item.status = 'failed'
|
||||
item.reason = error?.response ? '使用失败,请刷新检查原匹配' : '网络异常,使用结果未确认'
|
||||
// Unknown write outcomes are recovered by reading only, not by repeating POST.
|
||||
try {
|
||||
const response = await getReturnMatch(original.matchId, requestOptions)
|
||||
if (response === false) throw new Error('Unauthorized')
|
||||
const match = response.data?.match
|
||||
const sameOriginal = Number(match?.id) === Number(original.matchId) && Number(match?.sybProductId) === Number(original.sybProductId)
|
||||
if (sameOriginal && ['confirmed', 'cancelled'].includes(match.status)) {
|
||||
item.status = 'skipped'; item.reason = `回读原匹配已${match.status === 'confirmed' ? '确认使用' : '取消'},未重复操作`
|
||||
} else {
|
||||
item.retryable = sameOriginal && match.status === 'matched'
|
||||
item.reason += item.retryable ? ';原匹配仍待确认,可手动重试' : ';原匹配无法确认,请刷新检查'
|
||||
}
|
||||
} catch (readError) {
|
||||
if (isUnauthorized(readError)) authExpired = true
|
||||
item.reason += ';回读失败,请刷新检查,不自动重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.items.push(item); result.processed++
|
||||
result[`${item.status}Count`]++
|
||||
}
|
||||
if (this.returnConfirmStopped || authExpired) return
|
||||
const generation = this.loadGeneration + 1
|
||||
try {
|
||||
await this.load(false, true)
|
||||
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
|
||||
if (this.returnMatchLoadError || Object.values(this.purchaseReadiness).some(ready => ready.processNextAction === 'refresh')) {
|
||||
result.refreshError = '刷新处理阶段或退货匹配失败,请刷新列表后重试'; return
|
||||
}
|
||||
const failed = new Map(result.items.filter(item => item.status === 'failed' && item.retryable).map(item => [item.sybProductId, item.matchId]))
|
||||
await this.$nextTick()
|
||||
if (generation !== this.loadGeneration || this.returnConfirmStopped) return
|
||||
this.$refs.productTable?.clearSelection()
|
||||
this.products.filter(row => failed.get(row.id) === this.returnMatchByProductId[row.id]?.id && this.isReturnCancelCandidate(row)).forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
|
||||
} catch { result.refreshError = '列表刷新失败;执行结果已保留,请手动刷新,勿盲目重复使用' }
|
||||
} finally { this.returnConfirmBatchLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||
|
||||
@@ -172,24 +172,17 @@ export default {
|
||||
{ label: '退货待确认', value: 'matched' },
|
||||
{ label: '已用退货', value: 'confirmed' }
|
||||
],
|
||||
// yeeke's only confirmed claim-status value is the raw string "1"
|
||||
// (HAR evidence, #336 requirement doc). Any other value the sync sees
|
||||
// is preserved verbatim and flagged status_unrecognized — never
|
||||
// guessed into a label here. "unknown" is a server-side pseudo-filter
|
||||
// matching status_unrecognized=true, not a real yeeke value.
|
||||
// HAR-confirmed business statuses; unknown remains a server-side filter.
|
||||
claimStatusOptions: [
|
||||
{ label: '已认领', value: '1' },
|
||||
{ label: '待出货', value: '1' },
|
||||
{ label: '已重出', value: '2' },
|
||||
{ label: '未知(原样展示)', value: 'unknown' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() { this.load(); this.loadSummary(); this.loadShops() },
|
||||
methods: {
|
||||
// "1" is yeeke's only confirmed known status; anything else reaching
|
||||
// this function (status_unrecognized=false from the server) is an
|
||||
// unmapped value we have not seen yet — show it as-is rather than a
|
||||
// bare digit or a guessed Chinese label.
|
||||
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
|
||||
claimStatusMeta(status) { return { '1': { label: '待出货', type: 'success' }, '2': { label: '已重出', type: 'info' }}[status] || { label: status ? `其他:${status}` : '—', type: 'info' } },
|
||||
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
|
||||
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load() },
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const lists: URL[] = []; const writes: number[][] = []; const otherWrites: string[] = []
|
||||
let submitted = false
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }] }])
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/syb-inner-codes') && route.request().method() === 'GET') {
|
||||
lists.push(url)
|
||||
if (mode === 'list-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟加载失败' } })
|
||||
const statuses = ['pending', 'failed', 'skipped', 'updated']
|
||||
return reply({ items: statuses.map((status, i) => ({ id: i + 1, businessDate: mode === 'mixed-dates' && i === 1 ? '2026-09-28' : '2026-09-29', orderNumber: `MOCK-${i + 1}`, status: submitted && i < 3 ? 'matching' : status, items: [{ code: `MOCK-CODE-${i}`, ordinal: 1 }], specRaw: '模拟规格' })), total: 4 })
|
||||
}
|
||||
if (pathname.endsWith('/rematch')) {
|
||||
writes.push(route.request().postDataJSON().ids)
|
||||
if (mode === 'submit-error') return route.fulfill({ status: 409, json: { code: 'CONFLICT', message: '模拟状态已变化' } })
|
||||
submitted = true
|
||||
return reply({ matchJobId: 'isolated-job', queued: writes.at(-1)!.length })
|
||||
}
|
||||
if (pathname.includes('/match-jobs/')) return reply({ item: { id: 'isolated-job', status: 'running', total: 3, processed: 1, ready: 1, failed: 0 } })
|
||||
if (route.request().method() !== 'GET') otherWrites.push(pathname)
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
if (mode !== 'list-error') await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { lists, writes, otherWrites }
|
||||
}
|
||||
|
||||
test('today default, mixed selection only matches eligible rows and never writes SYB', async ({ page, context }) => {
|
||||
const { lists, writes, otherWrites } = await setup(page, context)
|
||||
const today = await page.evaluate(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` })
|
||||
expect(lists[0].searchParams.get('dateFrom')).toBe(today); expect(lists[0].searchParams.get('dateTo')).toBe(today)
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect.poll(() => writes.length).toBe(1); expect(writes[0]).toEqual([1, 2, 3])
|
||||
await expect(page.getByText('自动匹配:1/3,可回写 1,异常 0')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^匹配 \d+$/ })).toBeDisabled()
|
||||
expect(otherWrites).toEqual([])
|
||||
})
|
||||
|
||||
test('explicitly clear filters removes date range', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.getByRole('button', { name: '清除', exact: true }).click()
|
||||
await expect.poll(() => lists.length).toBeGreaterThan(1)
|
||||
expect(lists.at(-1)!.searchParams.get('dateFrom')).toBe('')
|
||||
})
|
||||
|
||||
test('mixed dates warn without submitting', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'mixed-dates')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByText('请按同一营业日期分别匹配')).toBeVisible(); expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test('submission failure keeps retry available without automatic retries', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, 'submit-error')
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '匹配 3', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '匹配 3', exact: true })).toBeEnabled()
|
||||
expect(writes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('list failure disables matching', async ({ page, context }) => {
|
||||
await setup(page, context, 'list-error')
|
||||
await expect(page.getByRole('button', { name: '匹配 0', exact: true })).toBeDisabled()
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const writes: number[] = []
|
||||
const cancelled = new Set<number>()
|
||||
const lists: URL[] = []
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/api/admin/v1/syb-products')) {
|
||||
lists.push(url)
|
||||
const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !cancelled.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
|
||||
return reply({ items, total: items.length })
|
||||
}
|
||||
if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: cancelled.has(id) ? 'pdd_unlinked' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: cancelled.has(id) ? '未关联 PDD' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
|
||||
if (pathname.endsWith('/return-matches')) {
|
||||
if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
|
||||
return reply({ items: [1, 2, 3].filter(id => !cancelled.has(id)).map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
|
||||
}
|
||||
if (/\/return-matches\/\d+\/cancel$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-2)); writes.push(id)
|
||||
expect(route.request().postDataJSON()).toEqual({ expectedStatus: 'matched' })
|
||||
if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟取消失败' } })
|
||||
if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
|
||||
if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
cancelled.add(id - 10); return reply({ item: { id, status: 'cancelled' } })
|
||||
}
|
||||
if (/\/return-matches\/\d+$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: 'matched' }, logs: [] })
|
||||
}
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { writes, lists }
|
||||
}
|
||||
|
||||
test('one confirmation, mixed selection, progress, success and filter retention', async ({ page, context }) => {
|
||||
const { writes, lists } = await setup(page, context)
|
||||
const cancel = page.getByRole('button', { name: /取消匹配\s*0/ }).first()
|
||||
await expect(cancel).toBeDisabled()
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
|
||||
await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
|
||||
await expect(page.getByText('取消选中的 2 条退货匹配?取消后商品将重新计算采购准备状态。', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回', exact: true }).click()
|
||||
expect(writes).toEqual([])
|
||||
await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
|
||||
await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual([11, 12])
|
||||
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const mode of ['partial', 'conflict', 'auth']) {
|
||||
test(`${mode}: per-row outcome, failed selection and stop on login failure`, async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, mode)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
|
||||
await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
if (mode === 'partial') {
|
||||
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('match load failure disables bulk cancellation', async ({ page, context }) => {
|
||||
await setup(page, context, 'metadata-error')
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await expect(page.getByRole('button', { name: /取消匹配\s*0/ }).first()).toBeDisabled()
|
||||
})
|
||||
test('unprivileged role cannot see cancellation or selection', async ({ page, context }) => {
|
||||
await setup(page, context, 'success', 'viewer')
|
||||
await expect(page.getByRole('button', { name: /取消匹配/ })).toHaveCount(0)
|
||||
await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('return-pending filter retained; successful rows leave current page', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
|
||||
await page.getByRole('option', { name: '退货待确认', exact: true }).click()
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /取消匹配\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认取消匹配', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, test, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
const menus = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }]
|
||||
async function setup(page: Page, context: BrowserContext, mode = 'success', role = 'after_sales') {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const writes: number[] = []
|
||||
const confirmed = new Set<number>()
|
||||
const lists: URL[] = []
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url()); const pathname = url.pathname
|
||||
if (pathname.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (pathname.endsWith('/api/v1/menurole')) return reply(menus)
|
||||
if (pathname.endsWith('/api/v1/getinfo')) return reply({ roles: [role], name: '模拟售后', avatar: '', permissions: [] })
|
||||
if (pathname.endsWith('/api/admin/v1/syb-products')) {
|
||||
lists.push(url)
|
||||
const items = [1, 2, 3, 4].filter(id => url.searchParams.get('processStage') !== 'return_pending' || (id <= 2 && !confirmed.has(id))).map(id => ({ id, orderCode: `MOCK-${id}`, shopeeItemId: String(100 + id), shopeeProductId: 100 + id, imageUrl: '', productTitle: '模拟商品', quantity: 1, parseStatus: 'success' }))
|
||||
return reply({ items, total: items.length })
|
||||
}
|
||||
if (pathname.endsWith('/purchase-tasks/batch-preview')) return reply({ items: [1, 2, 3, 4].map(id => ({ sybProductId: id, processStage: confirmed.has(id) ? 'return_used' : id <= 2 ? 'return_pending' : id === 3 ? 'return_used' : 'purchase_ready', processStageLabel: confirmed.has(id) ? '已用退货' : id <= 2 ? '退货待确认' : id === 3 ? '已用退货' : '可创建采购', eligible: id === 4, collectionEligible: false, aiMatchEligible: false })) })
|
||||
if (pathname.endsWith('/return-matches')) {
|
||||
if (mode === 'metadata-error') return route.fulfill({ status: 500, json: { code: 'INTERNAL' } })
|
||||
return reply({ items: [1, 2, 3].map(id => ({ id: id + 10, sybProductId: id, status: id <= 2 && !confirmed.has(id) ? 'matched' : 'confirmed', variationName: '模拟规格' })) })
|
||||
}
|
||||
if (/\/return-matches\/\d+\/confirm$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-2)); writes.push(id)
|
||||
expect(route.request().method()).toBe('POST')
|
||||
if (mode === 'readback-confirmed') return route.fulfill({ status: 504, json: { code: 'TIMEOUT' } })
|
||||
if (mode === 'partial' && id === 12) return route.fulfill({ status: 500, json: { code: 'INTERNAL', message: '模拟使用失败' } })
|
||||
if (mode === 'auth') return route.fulfill({ status: 401, json: { code: 401, message: '模拟登录失效' } })
|
||||
if (mode === 'conflict' && id === 12) return route.fulfill({ status: 409, json: { code: 'STATE_CONFLICT' } })
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
confirmed.add(id - 10); return reply({ item: { id, status: 'confirmed' } })
|
||||
}
|
||||
if (/\/return-matches\/\d+$/.test(pathname)) {
|
||||
const id = Number(pathname.split('/').at(-1)); return reply({ match: { id, sybProductId: id - 10, status: mode === 'readback-confirmed' || confirmed.has(id - 10) ? 'confirmed' : 'matched' }, logs: [] })
|
||||
}
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-1', exact: true })).toBeVisible()
|
||||
return { writes, lists }
|
||||
}
|
||||
|
||||
test('v2 button order, one confirmation, no write on return, progress and mutual exclusion', async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context)
|
||||
const use = page.getByRole('button', { name: /使用退货\s*0/ })
|
||||
await expect(use).toBeDisabled()
|
||||
const toolbar = await page.locator('.toolbar-actions button').allTextContents()
|
||||
expect(toolbar.findIndex(t => t.includes('匹配退货'))).toBeLessThan(toolbar.findIndex(t => t.includes('取消匹配')))
|
||||
expect(toolbar.findIndex(t => t.includes('取消匹配'))).toBeLessThan(toolbar.findIndex(t => t.includes('使用退货')))
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await expect(page.getByText(/不会自动让 Yeeke 发货/, { exact: false }).last()).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回', exact: true }).click()
|
||||
expect(writes).toEqual([])
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: /取消匹配\s*2/ }).first()).toBeDisabled()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual([11, 12])
|
||||
await expect(page.getByText('成功 2 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
|
||||
test(`${mode}: independent outcomes, failed selection, auth stop and readback without retry`, async ({ page, context }) => {
|
||||
const { writes } = await setup(page, context, mode)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(writes).toEqual(mode === 'auth' ? [11] : [11, 12])
|
||||
const summary = mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : mode === 'conflict' ? '跳过 2 条' : '跳过 3 条'
|
||||
await expect(page.getByText(summary, { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
if (mode === 'partial') {
|
||||
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('metadata load error disables use', async ({ page, context }) => {
|
||||
await setup(page, context, 'metadata-error')
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await expect(page.getByRole('button', { name: /使用退货\s*0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('viewer cannot see use button or selection', async ({ page, context }) => {
|
||||
await setup(page, context, 'success', 'viewer')
|
||||
await expect(page.getByRole('button', { name: /使用退货/ })).toHaveCount(0)
|
||||
await expect(page.locator('thead .el-checkbox')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('pending filter and page size retained; used rows disappear', async ({ page, context }) => {
|
||||
const { lists } = await setup(page, context)
|
||||
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select__wrapper').click()
|
||||
await page.getByRole('option', { name: '退货待确认', exact: true }).click()
|
||||
await page.getByRole('button', { name: '查询', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-4', exact: true })).toHaveCount(0)
|
||||
await page.locator('thead .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: /使用退货\s*2/ }).click()
|
||||
await page.getByRole('button', { name: '确认使用', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeEnabled()
|
||||
expect(lists.at(-1)?.searchParams.get('processStage')).toBe('return_pending')
|
||||
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
|
||||
await expect(page.getByText('暂无 SYB 商品明细', { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
function instance(write = async () => ({ data: { matchJobId: 'test-job', queued: 2 } })) {
|
||||
const writes = []; const messages = []
|
||||
const options = new Function('rematchSybInnerCodes', 'createRequestId', 'ElMessage', source)(async body => { writes.push(body); return write(body) }, () => 'isolated-id', { success: m => messages.push(m), warning: m => messages.push(m) })
|
||||
const vm = { ...options.data(), ...options.methods, load: async () => {}, startPolling: () => {} }
|
||||
for (const [key, get] of Object.entries(options.computed)) Object.defineProperty(vm, key, { get: () => get.call(vm) })
|
||||
vm.items = ['pending', 'failed', 'skipped', 'updated', 'ready', 'needs_check', 'matching'].map((status, i) => ({ id: i + 1, status, businessDate: '2026-09-29' }))
|
||||
return { vm, writes, messages }
|
||||
}
|
||||
test('initial range and request use local today, clear explicitly removes it', () => {
|
||||
const { vm } = instance(); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
assert.deepEqual(vm.dateRange, [today, today]); assert.equal(vm.query.dateFrom, today); assert.equal(vm.query.dateTo, today)
|
||||
vm.clearFilters(); assert.equal(vm.dateRange, null); assert.equal(vm.query.dateFrom, '')
|
||||
})
|
||||
test('mixed selection submits only current page eligible rows, does not auto apply', async () => {
|
||||
const { vm, writes } = instance(); vm.selected = [...vm.items, { id: 999, status: 'failed' }]
|
||||
assert.equal(vm.matchEligibleCount, 3); await vm.matchSelected(); assert.deepEqual(writes[0].ids, [1, 2, 3]); assert.equal(writes.length, 1); assert.equal(vm.activeJob.id, 'test-job'); assert.equal(vm.activeBatch, null)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 1)
|
||||
})
|
||||
test('busy, loading, error and empty selection do not submit', async () => {
|
||||
for (const field of ['busy', 'importing', 'loading', 'loadError']) {
|
||||
const { vm, writes } = instance(); vm.selected = [vm.items[0]]; vm[field] = field === 'loadError' ? 'error' : true
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
}
|
||||
const { vm, writes } = instance(); await vm.matchSelected(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('mixed dates warn without writes', async () => {
|
||||
const { vm, writes, messages } = instance(); vm.items[1].businessDate = '2026-09-28'; vm.selected = vm.items.slice(0, 2)
|
||||
await vm.matchSelected(); assert.equal(writes.length, 0); assert.equal(messages.length, 1)
|
||||
})
|
||||
test('submission error retains selection and releases busy flag', async () => {
|
||||
const { vm } = instance(async () => { throw new Error('test-only error') }); vm.selected = [vm.items[0]]
|
||||
await assert.rejects(vm.matchSelected()); assert.equal(vm.matchSubmitting, false); assert.equal(vm.selected.length, 1); assert.equal(vm.activeJob, null)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/return-cancel.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
function instance(overrides = {}) {
|
||||
const writes = []; const reads = []; const selected = []; const confirmations = []
|
||||
const dependencies = {
|
||||
cancelReturnMatch: async (id, body) => { writes.push({ id, body }) },
|
||||
getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
|
||||
}
|
||||
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
|
||||
const vm = { ...options.methods, ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
|
||||
products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
|
||||
purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
|
||||
returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
|
||||
$confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
|
||||
$refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
|
||||
load: async function() { this.loadGeneration++ }
|
||||
}
|
||||
vm.selectedProducts = [...vm.products]
|
||||
Object.entries(options.computed).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
|
||||
return { vm, options, writes, reads, selected, confirmations }
|
||||
}
|
||||
test('matched pending count only; serial requests include frozen matched precondition and skip confirmed', async () => {
|
||||
const { vm, writes, confirmations } = instance()
|
||||
assert.equal(vm.returnCancelCandidates.length, 2)
|
||||
await vm.runBatchCancelReturns()
|
||||
assert.deepEqual(writes, [{ id: 11, body: { expectedStatus: 'matched' } }, { id: 12, body: { expectedStatus: 'matched' } }])
|
||||
assert.match(confirmations[0], /2 条/)
|
||||
assert.equal(vm.returnCancelResult.successCount, 2); assert.equal(vm.returnCancelResult.skippedCount, 1)
|
||||
assert.equal(vm.returnCancelBatchLoading, false)
|
||||
})
|
||||
test('permission, empty, metadata failure/loading and conflicting actions disable entry', async () => {
|
||||
for (const [key, value] of [['canPurchase', false], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true]]) {
|
||||
const { vm, writes } = instance(); vm[key] = value; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
|
||||
}
|
||||
const { vm, writes } = instance(); vm.selectedProducts = []; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('confirmation dismissal does not write', async () => {
|
||||
const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchCancelReturns()
|
||||
assert.equal(writes.length, 0); assert.equal(vm.returnCancelResult.open, false); assert.equal(vm.returnCancelBatchLoading, false)
|
||||
})
|
||||
test('freeze identities, dedupe, prevent duplicate execution and serialize', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
|
||||
const { vm } = instance({ cancelReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
|
||||
vm.selectedProducts.push(vm.products[0]); const running = vm.runBatchCancelReturns(); await tick()
|
||||
vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchCancelReturns()
|
||||
assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
|
||||
})
|
||||
test('partial failure retains only readback-confirmed eligible failed row', async () => {
|
||||
const { vm, selected, reads } = instance({ cancelReturnMatch: async id => { if (id === 12) throw { response: { status: 403 } } } })
|
||||
await vm.runBatchCancelReturns()
|
||||
assert.equal(vm.returnCancelResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
|
||||
})
|
||||
test('state conflict skips and never recancels original or replacement', async () => {
|
||||
const { vm, reads } = instance({ cancelReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
|
||||
await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.equal(reads.length, 0)
|
||||
})
|
||||
test('uncertain timeout reads original once, no blind POST retry', async () => {
|
||||
let writes = 0
|
||||
const { vm, reads, selected } = instance({ cancelReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, status: 'cancelled' } } } } })
|
||||
await vm.runBatchCancelReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12]); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.deepEqual(selected, [])
|
||||
})
|
||||
test('failed readback leaves uncertain results unselected', async () => {
|
||||
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('offline') }, getReturnMatch: async () => { throw new Error('offline') } })
|
||||
await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
})
|
||||
for (const error of [new Error('Unauthorized'), { response: { status: 401 } }]) {
|
||||
test(`auth failure stops remaining writes (${error.message || 'HTTP'})`, async () => {
|
||||
let writes = 0; const { vm } = instance({ cancelReturnMatch: async () => { writes++; throw error } })
|
||||
await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.failedCount, 2); assert.equal(vm.returnCancelBatchLoading, false)
|
||||
})
|
||||
}
|
||||
test('new query generation does not restore stale selection', async () => {
|
||||
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
|
||||
vm.load = async () => { vm.loadGeneration += 2 }; await vm.runBatchCancelReturns(); assert.deepEqual(selected, [])
|
||||
})
|
||||
test('filter removes failed row but keeps failure result; refresh failure preserves result', async () => {
|
||||
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
|
||||
vm.load = async () => { vm.loadGeneration++; vm.products = [] }; await vm.runBatchCancelReturns()
|
||||
assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
vm.products = [{ id: 1 }]; vm.selectedProducts = vm.products; vm.load = async () => { throw new Error('offline') }; await vm.runBatchCancelReturns()
|
||||
assert.match(vm.returnCancelResult.refreshError, /刷新失败/)
|
||||
})
|
||||
test('unmount prevents further writes after in-flight request', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
|
||||
const { vm, options } = instance({ cancelReturnMatch: async () => { writes++; await wait } })
|
||||
const running = vm.runBatchCancelReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
|
||||
assert.equal(writes, 1); assert.equal(vm.returnCancelBatchLoading, false)
|
||||
})
|
||||
|
||||
test('legacy interceptor false/6401 result is auth failure, not cancellation success', async () => {
|
||||
let writes = 0
|
||||
const { vm } = instance({ cancelReturnMatch: async () => { writes++; return false } })
|
||||
await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.successCount, 0); assert.equal(vm.returnCancelResult.failedCount, 2)
|
||||
})
|
||||
|
||||
test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => {
|
||||
const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
|
||||
const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {})
|
||||
const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' }
|
||||
const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 }
|
||||
const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true }
|
||||
assert.equal(vm.isSelectableCandidate(row), true)
|
||||
for (const method of ['isPurchaseCandidate', 'isCollectionCandidate', 'isAIMatchCandidate', 'isImageSearchCandidate', 'isReturnMatchCandidate']) assert.equal(vm[method](row), false, method)
|
||||
ready.processStage = 'return_used'; assert.equal(vm.isSelectableCandidate(row), false)
|
||||
ready.processStage = 'purchase_ready'; assert.equal(vm.isPurchaseCandidate(row), true)
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const readOptions = (file, dependencies) => new Function(...Object.keys(dependencies), fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products', file), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return'))(...Object.values(dependencies))
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
function instance(overrides = {}) {
|
||||
const writes = []; const reads = []; const selected = []; const confirmations = []
|
||||
const dependencies = {
|
||||
confirmReturnMatch: async (id, options) => { writes.push({ id, options }); return { code: 200 } },
|
||||
getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
|
||||
}
|
||||
const options = readOptions('return-confirm.js', dependencies)
|
||||
const cancel = readOptions('return-cancel.js', { cancelReturnMatch: async () => {}, getReturnMatch: dependencies.getReturnMatch })
|
||||
const vm = { ...cancel.methods, ...options.methods, ...cancel.data(), ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
|
||||
products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
|
||||
purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
|
||||
returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
|
||||
purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
|
||||
$confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
|
||||
$refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
|
||||
load: async function() { this.loadGeneration++ }
|
||||
}
|
||||
vm.selectedProducts = [...vm.products]
|
||||
Object.entries({ ...cancel.computed, ...options.computed }).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
|
||||
return { vm, options, writes, reads, selected, confirmations }
|
||||
}
|
||||
test('current-page count, deduplication, mixed rows, original confirm API and one confirmation', async () => {
|
||||
const { vm, writes, confirmations } = instance()
|
||||
vm.selectedProducts.push(vm.products[0], { id: 99 })
|
||||
assert.equal(vm.returnConfirmCandidates.length, 2)
|
||||
await vm.runBatchConfirmReturns()
|
||||
assert.deepEqual(writes.map(w => w.id), [11, 12])
|
||||
assert.ok(writes.every(w => w.options.suppressErrorMessage && w.options.suppressNetworkError))
|
||||
assert.equal(confirmations.length, 1); assert.match(confirmations[0], /2 条/); assert.match(confirmations[0], /不会自动让 Yeeke 发货/)
|
||||
assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(vm.returnConfirmResult.skippedCount, 1)
|
||||
assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
})
|
||||
test('permission, metadata, busy and empty states prohibit writes', async () => {
|
||||
for (const [key, value] of [['canPurchase', false], ['loading', true], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true], ['returnCancelBatchLoading', true]]) {
|
||||
const { vm, writes } = instance(); vm[key] = value; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0, key)
|
||||
}
|
||||
const { vm, writes } = instance(); vm.matchDetail.saving = true; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
|
||||
vm.matchDetail.saving = false; vm.selectedProducts = []; await vm.runBatchConfirmReturns(); assert.equal(writes.length, 0)
|
||||
})
|
||||
test('return dismisses without a write or result; confirm busy blocks cancel and single transitions', async () => {
|
||||
const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchConfirmReturns()
|
||||
assert.equal(writes.length, 0); assert.equal(vm.returnConfirmResult.open, false); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
vm.returnConfirmBatchLoading = true; let ran = false
|
||||
assert.match(vm.returnCancelButtonReason, /正在处理/)
|
||||
await vm.runReturnTransition(async () => { ran = true }); assert.equal(ran, false)
|
||||
})
|
||||
test('serial, frozen identities and no duplicate execution while waiting', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
|
||||
const { vm } = instance({ confirmReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
|
||||
const running = vm.runBatchConfirmReturns(); await tick()
|
||||
vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchConfirmReturns()
|
||||
assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
|
||||
})
|
||||
test('partial business failure keeps only readback-proven current-page failed selection', async () => {
|
||||
const { vm, selected, reads } = instance({ confirmReturnMatch: async id => { if (id === 12) throw { response: { status: 500 } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
|
||||
})
|
||||
test('state conflict skips original; neither readback nor replacement write', async () => {
|
||||
const { vm, reads } = instance({ confirmReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.skippedCount, 3); assert.equal(reads.length, 0)
|
||||
})
|
||||
for (const status of ['confirmed', 'cancelled', 'matched']) {
|
||||
test(`unknown outcome reads original ${status} once, never resends`, async () => {
|
||||
let writes = 0
|
||||
const { vm, reads, selected } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status } } } } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12])
|
||||
assert.equal(vm.returnConfirmResult.successCount, 0)
|
||||
assert.deepEqual(selected, status === 'matched' ? [1, 2] : [])
|
||||
assert.equal(vm.returnConfirmResult.skippedCount, status === 'matched' ? 1 : 3)
|
||||
})
|
||||
}
|
||||
test('unreadable/mismatched original never treated as confirmed or retryable', async () => {
|
||||
for (const getReturnMatch of [async () => { throw new Error('offline') }, async id => ({ data: { match: { id: id + 1, sybProductId: 999, status: 'confirmed' } } })]) {
|
||||
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') }, getReturnMatch })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
}
|
||||
})
|
||||
for (const response of [false, new Error('Unauthorized'), { response: { status: 401 } }]) {
|
||||
test(`auth failure/legacy false stops remaining writes (${String(response)})`, async () => {
|
||||
let writes = 0
|
||||
const { vm } = instance({ confirmReturnMatch: async () => { writes++; if (response === false) return false; throw response } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
assert.match(vm.returnConfirmResult.items[1].reason, /未继续执行/)
|
||||
})
|
||||
}
|
||||
test('login failure during readback stops remaining POSTs', async () => {
|
||||
let writes = 0
|
||||
const { vm } = instance({ confirmReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async () => { throw new Error('Unauthorized') } })
|
||||
await vm.runBatchConfirmReturns(); assert.equal(writes, 1); assert.equal(vm.returnConfirmResult.failedCount, 2)
|
||||
})
|
||||
test('new generation, replaced original and vanished failed row cannot restore old selection', async () => {
|
||||
for (const load of [async function() { this.loadGeneration += 2 }, async function() { this.loadGeneration++; this.products = [] }, async function() { this.loadGeneration++; this.returnMatchByProductId[1].id = 99; this.returnMatchByProductId[2].id = 98 }]) {
|
||||
const { vm, selected } = instance({ confirmReturnMatch: async () => { throw new Error('timeout') } }); vm.load = load
|
||||
await vm.runBatchConfirmReturns(); assert.equal(vm.returnConfirmResult.failedCount, 2); assert.deepEqual(selected, [])
|
||||
}
|
||||
})
|
||||
test('refresh failure keeps completed result and does not write again', async () => {
|
||||
const { vm, writes } = instance(); vm.load = async () => { throw new Error('offline') }; await vm.runBatchConfirmReturns()
|
||||
assert.match(vm.returnConfirmResult.refreshError, /刷新失败/); assert.equal(vm.returnConfirmResult.successCount, 2); assert.equal(writes.length, 2)
|
||||
})
|
||||
test('unmount stops remaining writes after in-flight response', async () => {
|
||||
let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
|
||||
const { vm, options } = instance({ confirmReturnMatch: async () => { writes++; await wait } })
|
||||
const running = vm.runBatchConfirmReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
|
||||
assert.equal(writes, 1); assert.equal(vm.returnConfirmBatchLoading, false)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const file = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/yeeke-returns/index.vue'), 'utf8')
|
||||
const script = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import[^\n]+\n/gm, '').replace('export default', 'return')
|
||||
const options = new Function(script)()
|
||||
|
||||
test('Yeeke status filters and table/detail labels agree for both HAR statuses', () => {
|
||||
const filters = options.data().claimStatusOptions
|
||||
for (const [value, label] of [['1', '待出货'], ['2', '已重出']]) {
|
||||
assert.equal(filters.find(item => item.value === value).label, label)
|
||||
assert.equal(options.methods.claimStatusMeta(value).label, label)
|
||||
}
|
||||
assert.equal(filters.find(item => item.value === 'unknown').label, '未知(原样展示)')
|
||||
assert.equal(options.methods.claimStatusMeta('9').label, '其他:9')
|
||||
})
|
||||
|
||||
test('clearing the status filter restores all records and reloads', () => {
|
||||
let reloads = 0
|
||||
const vm = { ...options.data(), load() { reloads++ } }
|
||||
vm.query.claimStatus = '2'
|
||||
options.methods.reset.call(vm)
|
||||
assert.equal(vm.query.claimStatus, '')
|
||||
assert.equal(vm.query.page, 1)
|
||||
assert.equal(reloads, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user