Compare commits

..
6 changed files with 105 additions and 7 deletions
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 975633525ff82f4be77b37b3d41f08aa05de6822
synchronized_at: 2026-09-19T07:18:31Z
wiki_revision: b6df3e2d3d497827c9f3cc5b5ec859d5bda3c1f2
synchronized_at: 2026-09-19T07:39:56Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -656,3 +656,9 @@ Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
## 采购管理已回填 SYB 筛选(#327)
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
+10 -2
View File
@@ -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: e43e83af5442005139b3234963afd317609b44ac
synchronized_at: 2026-09-19T07:18:51Z
wiki_revision: 275caa306765440b0888dea701198d45481bfb40
synchronized_at: 2026-09-19T07:40:19Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1391,3 +1391,11 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
## Admin 采购列表已回填单号筛选(#327)
实现368f2c2(2026-09-19),feat/327-syb-writeback-filter;测试和构建通过,未合并发布。
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变更。
+7 -2
View File
@@ -109,6 +109,9 @@ type AdminDetailResponse struct {
Matching MatchingView `json:"matching"`
}
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
if req.Page < 1 {
req.Page = 1
@@ -119,7 +122,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Status != "" && !validPurchaseStatus(req.Status) {
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
}
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
@@ -132,7 +135,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
if req.Status != "" {
if req.Status == adminStatusSYBWritebackSucceeded {
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
} else if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.ExecutionMode != "" {
@@ -105,6 +105,63 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
}
}
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
s, first := orderWritebackFixture(t)
db := s.DB
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
t.Fatal(err)
}
var successIDs []uint64
successIDs = append(successIDs, first.ID)
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
task := first
task.ID = 0
task.Status = models.PurchaseTaskStatusCancelled
task.PDDOrderNo = nil
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
task.CreateRequestID = uuid.NewString()
task.UnknownResolveRequestID = nil
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if state != "none" {
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
t.Fatal(err)
}
}
if state == "succeeded" {
successIDs = append(successIDs, task.ID)
}
}
for page := 1; page <= 2; page++ {
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
t.Fatalf("page %d: %+v, %v", page, out, err)
}
}
for _, req := range []AdminListRequest{
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
{Status: models.PurchaseTaskStatusOrderCreated},
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
} {
out, err := s.AdminList(context.Background(), req)
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
t.Fatalf("combined filter: %+v %v", out, err)
}
}
out, err := s.AdminList(context.Background(), AdminListRequest{})
if err != nil || out.Total != 8 {
t.Fatalf("clear filter: %+v %v", out, err)
}
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
if err != nil || out.Total != 0 {
t.Fatalf("empty filter: %+v %v", out, err)
}
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
t.Fatal("filter became task state")
}
}
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
@@ -157,7 +157,7 @@ export default {
setup() { return { RefreshLeft, Search } },
data() {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions,
loading: false, loadError: '', tasks: [], total: 0, statuses: [...statusOptions, { value: 'syb_writeback_succeeded', label: '已回填 SYB' }],
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
@@ -82,6 +82,28 @@ test('冲突显示原因且没有覆盖按钮', async ({ page }) => {
await expect(drawer.getByRole('button', { name: /回填 SYB 单号/ })).toHaveCount(0)
})
test('状态下拉框筛选已回填SYB,清空后恢复普通列表', async ({ page }) => {
const requests = await setup(page, 'succeeded')
const statuses: string[] = []
await page.route('**/api/admin/v1/purchase-tasks?**', async route => {
const query = new URL(route.request().url()).searchParams
statuses.push(query.get('status') || '')
expect(query.get('page')).toBe('1')
return route.fulfill({ json: { code: 200, data: { items: [{ id: 11, taskType: 'syb_order', executionMode: 'live', status: 'order_created', orderWriteback: { status: 'succeeded', canSubmit: false } }], total: 1 } } })
})
const field = page.locator('.search-form .el-form-item').filter({ has: page.locator('label').filter({ hasText: /^状态$/ }) })
await field.locator('.el-select').click()
await page.getByRole('option', { name: '已回填 SYB', exact: true }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => statuses.at(-1)).toBe('syb_writeback_succeeded')
await expect(page.getByText('订单已创建', { exact: true }).first()).toBeVisible()
await field.locator('.el-select').hover()
await field.locator('.el-select__clear').click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => statuses.at(-1)).toBe('')
expect(requests).toHaveLength(0)
})
for (const amount of ['0', '13.01']) {
test(`已创建订单补金额 ${amount},不重新采购或修改支付状态`, async ({ page }) => {
const requests = await setup(page)