diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 7b48293..e16523b 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -64,6 +64,13 @@ func main() { os.Exit(1) } fmt.Printf("mock gasorder statuses repaired: %d\n", count) + case "repair-mock-track-point": + count, err := repairMockTrackPoint() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("mock track points repaired: %d\n", count) case "migrate": if err := migrateDatabase(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -78,7 +85,7 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, "usage: platform-cli ") + fmt.Fprintln(os.Stderr, "usage: platform-cli ") } type route struct { @@ -209,6 +216,27 @@ func repairMockGasorderStatus() (int64, error) { return count, nil } +// repairMockTrackPoint 使用独立幂等命令补全固定演示轨迹点,不触发其他模拟数据写入。 +func repairMockTrackPoint() (int64, error) { + config.New(serviceKey) + if config.Spec.Databases == nil { + return 0, fmt.Errorf("database configuration is required") + } + databaseService, err := database.NewDatabase( + config.Spec.Databases.Driver, + config.Spec.Databases.Source, + dbsql.SetOptions(nil), + ) + if err != nil { + return 0, fmt.Errorf("connect database: %w", err) + } + count, err := seed.RepairMockTrackPointSample(databaseService) + if err != nil { + return 0, fmt.Errorf("repair mock track point: %w", err) + } + return count, nil +} + func migrateDatabase() error { config.New(serviceKey) options := &types.SqlOptions{ diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 0af7e4a..7075a1c 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -252,7 +252,10 @@ func MockData(database *gorm.DB) error { trackPoint := models.GasorderTrackPoint{ Entity: entity(23, common.StatusEnable), GasorderTrackID: track.ID, Longitude: address.Longitude, Latitude: address.Latitude, - OccurredAt: trackCompletedAt, Source: "gps", Accuracy: "10m", + RequestNo: mockTrackPointRequestNo, OccurredAt: trackCompletedAt, + ReceivedAt: trackCompletedAt.Add(mockTrackPointReceiveDelay), + Source: "gps", Accuracy: "10m", Speed: mockTrackPointSpeed, + Direction: mockTrackPointDirection, } if err := put(tx, &trackPoint); err != nil { return err @@ -786,6 +789,43 @@ func RepairMockGasorderInitialStatuses(database *gorm.DB) (int64, error) { return result.RowsAffected, nil } +const ( + mockTrackPointRequestNo = "MOCK-TRACK-POINT-001" + mockTrackPointSpeed = "12 km/h" + mockTrackPointDirection = "东北(45°)" + mockTrackPointReceiveDelay = 2 * time.Second +) + +// RepairMockTrackPointSample 仅补全固定演示轨迹点的缺失上报字段,不覆盖已有有效数据。 +func RepairMockTrackPointSample(database *gorm.DB) (int64, error) { + var point models.GasorderTrackPoint + identity := fmt.Sprintf("%s%012d", mockIdentityPrefix, 23) + if err := database.Where("identity = ?", identity).First(&point).Error; err != nil { + return 0, fmt.Errorf("find mock track point sample: %w", err) + } + updates := map[string]any{} + if point.RequestNo == "" { + updates["request_no"] = mockTrackPointRequestNo + } + if point.ReceivedAt.IsZero() { + updates["received_at"] = point.OccurredAt.Add(mockTrackPointReceiveDelay) + } + if point.Speed == "" { + updates["speed"] = mockTrackPointSpeed + } + if point.Direction == "" { + updates["direction"] = mockTrackPointDirection + } + if len(updates) == 0 { + return 0, nil + } + result := database.Model(&point).Updates(updates) + if result.Error != nil { + return 0, fmt.Errorf("repair mock track point sample: %w", result.Error) + } + return result.RowsAffected, nil +} + // linkMockProductProducer 为未来生成的 Mock 智能气阀建立真实生产商关联。 func linkMockProductProducer(product *models.ProductInfo, producer models.ProducerAccount) error { if product == nil || producer.ID == 0 { diff --git a/backend/api/internal/seed/mock_test.go b/backend/api/internal/seed/mock_test.go index a7b87dc..81fe6b1 100644 --- a/backend/api/internal/seed/mock_test.go +++ b/backend/api/internal/seed/mock_test.go @@ -93,3 +93,16 @@ func TestMockGasorderInitialStatusMatchesWorkflow(t *testing.T) { t.Fatal("Mock 配送订单不得使用通用待处理状态") } } + +// TestMockTrackPointSampleRequiredFields 验证演示轨迹点符合真实上报的必填字段和可读展示要求。 +func TestMockTrackPointSampleRequiredFields(t *testing.T) { + if mockTrackPointRequestNo == "" { + t.Fatal("Mock 轨迹点必须包含上报流水号") + } + if mockTrackPointReceiveDelay <= 0 { + t.Fatal("Mock 轨迹点接收时间必须晚于定位时间") + } + if mockTrackPointSpeed == "" || mockTrackPointDirection == "" { + t.Fatal("演示轨迹点必须提供可读的速度和方向") + } +} diff --git a/docs/操作日志_运行轨迹关联名称显示优化_20260815.md b/docs/操作日志_运行轨迹关联名称显示优化_20260815.md index 651df41..30bb0a9 100644 --- a/docs/操作日志_运行轨迹关联名称显示优化_20260815.md +++ b/docs/操作日志_运行轨迹关联名称显示优化_20260815.md @@ -17,6 +17,8 @@ - 工作人员列改为展示人员姓名,列名统一为“配送人员”。 - 配送人员关联明确限定 `delivery` 岗位,但历史详情不附加启用、在岗或资质有效过滤,确保离岗人员的历史轨迹仍可回看。 - 详情接口的 `{ track, points }` 聚合响应新增主记录解包与轨迹点固定中文列契约。 +- 固定 Mock 轨迹点补齐上报流水号、服务端接收时间、速度和方向;新增独立幂等修复命令,不修改真实轨迹。 +- 轨迹点“行进方向”按原始采集值展示,避免与钱包“收支方向”的同名字段枚举冲突。 - 两列继续保留完整系统唯一标识的悬浮提示、复制入口和关联详情跳转能力。 - 增加展示契约检查,防止运行轨迹退回裸唯一标识展示。 @@ -32,6 +34,9 @@ - `frontend/platform_admin/scripts/check-staff-relation-policy.mjs`:补充历史轨迹人员角色与不过滤当前状态的契约检查。 - `frontend/platform_admin/src/api/resource-display.ts`:支持运行轨迹主记录解包及轨迹点专属中文字段语义。 - `frontend/platform_admin/src/api/resource-detail-contract.ts`:声明运行轨迹基本信息顺序、关联资源和轨迹点固定列。 +- `backend/api/internal/seed/mock.go`:修正演示轨迹点生成值并提供精确范围的数据修复函数。 +- `backend/api/cmd/cli/main.go`:新增 `repair-mock-track-point` 独立修复命令。 +- `backend/api/internal/seed/mock_test.go`:补充演示轨迹点字段完整性测试。 ## 风险评估 @@ -47,3 +52,6 @@ - Codex 内置浏览器刷新运行轨迹后,配送订单显示 `MOCK-GASORDER-001`,配送人员显示“王师傅”;原截断标识不再作为主文案,两个完整唯一标识仍可复制。 - 修复详情页工作人员关联缺少显式角色策略导致的加载失败,并重新验证详情页。 - 浏览器详情页验证通过:加载失败提示数量为 0;基本信息正确显示订单号、配送人员姓名、尝试次数和时间,关联记录按固定中文列显示轨迹点。 +- 执行 `go run ./cmd/cli repair-mock-track-point`,精确修复 1 条固定 Mock 轨迹点。 +- 最终浏览器验证:零时间数量为 0;接收时间为 `2026-07-01 08:50:02`,速度为 `12 km/h`,方向为“东北(45°)”,上报流水号为 `MOCK-TRACK-POINT-001`。 +- 后端 `go test ./...`、前端类型检查、生产构建和展示契约检查均通过。 diff --git a/frontend/platform_admin/scripts/check-resource-display-contracts.mjs b/frontend/platform_admin/scripts/check-resource-display-contracts.mjs index 3a008dd..e7a7578 100644 --- a/frontend/platform_admin/scripts/check-resource-display-contracts.mjs +++ b/frontend/platform_admin/scripts/check-resource-display-contracts.mjs @@ -116,5 +116,10 @@ assertIncludes( "if (detail.track && typeof detail.track === 'object')", '运行轨迹详情必须解包 track 聚合主记录', ); +assertIncludes( + detailContract, + "rawColumns: ['direction']", + '轨迹点行进方向必须保留原始采集值,不得套用收支方向枚举', +); process.stdout.write('平台48类资源中文展示契约检查通过\n'); diff --git a/frontend/platform_admin/src/api/resource-detail-contract.ts b/frontend/platform_admin/src/api/resource-detail-contract.ts index 7089311..a74886d 100644 --- a/frontend/platform_admin/src/api/resource-detail-contract.ts +++ b/frontend/platform_admin/src/api/resource-detail-contract.ts @@ -6,6 +6,7 @@ export type ResourceCollectionContract = { columns: string[]; jsonColumns?: string[]; + rawColumns?: string[]; relationIdentityKeys?: Record; }; @@ -115,6 +116,7 @@ const contracts: Record = { 'occurred_at', 'received_at', 'longitude', 'latitude', 'accuracy', 'speed', 'direction', 'source', 'request_no', ], + rawColumns: ['direction'], }, }, }, @@ -173,3 +175,15 @@ export function isCollectionJsonField( ?.jsonColumns?.includes(column), ); } + +/** 判断集合字段是否必须保留接口原始值,避免同名全局枚举误用到其他业务语义。 */ +export function isCollectionRawField( + resourceName: string, + collectionKey: string, + column: string, +) { + return Boolean( + contracts[resourceName]?.collections?.[collectionKey] + ?.rawColumns?.includes(column), + ); +} diff --git a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue index 9fd0d50..1e2d8f8 100644 --- a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue +++ b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue @@ -42,7 +42,7 @@ > - + @@ -81,6 +81,7 @@ import { import { collectionRelationIdentityKey, isCollectionJsonField, + isCollectionRawField, isResourceJsonField, resourceDetailContract, } from '@/api/resource-detail-contract'; @@ -134,6 +135,18 @@ function collectionColumnWidth(collectionKey: string, column: string) { return 160; } +/** 按聚合集合契约格式化单元格,专属原始字段不得套用同名全局枚举。 */ +function displayCollectionValue( + collectionKey: string, + column: string, + value: unknown, +) { + if (isCollectionRawField(props.definition.name, collectionKey, column)) { + return value == null || value === '' ? '-' : String(value); + } + return displayRawValue(column, value); +} + /** 将字符串或对象参数格式化为可读 JSON;历史非 JSON 文本保持原样。 */ function formatCollectionJson(value: unknown) { if (value == null || value === '') return '暂无参数';