diff --git a/.cursor/skills/oms-auto-trigger-formula/SKILL.md b/.cursor/skills/oms-auto-trigger-formula/SKILL.md new file mode 100644 index 0000000..083108f --- /dev/null +++ b/.cursor/skills/oms-auto-trigger-formula/SKILL.md @@ -0,0 +1,123 @@ +--- +name: oms-auto-trigger-formula +description: >- + Writes OMS auto-trigger (自动触发) Chinese formulas for /channel/autoTrigger. + Use when the user asks for 触发条件, 自动触发公式, 拦截/警告/问题件规则, + oversize/国家/客户/销售产品条件, or formula verification sample data. +--- + +# OMS 自动触发公式编写 + +为 `/channel/autoTrigger` 生成可直接粘贴的**中文公式**(后端 `ChineseFormulaEvaluator`)。 + +源码权威: + +- 前端字段/算子:`src/views/channel/autoTrigger/formulaMeta.ts` +- 后端解析:`Oms.Application/.../ChineseFormulaEvaluator.cs`、`AutoTriggerFormulaMeta.cs` + +## 产出格式 + +回复必须包含: + +1. **时机**:`下单` | `入库` | `出库`(无「预报」;旧「预报」=「下单」) +2. **操作**:`拦截` | `警告` | `问题件` +3. **公式**:单行可执行表达式(无解释性注释夹在公式内) +4. **一句话说明**:命中条件是什么 +5. (可选)**验证样例**:国家/尺寸/客户等,方便弹窗「验证触发条件」 + +```text +时机:下单 +操作:拦截 +公式:(单件最大长度()>60||单件最大宽度()>46)&&国家包含('GB') +说明:英国且超尺寸时拦截 +验证样例:国家 GB,长 70,宽 40,高 40 +``` + +## 语法硬规则(必遵守) + +| 规则 | 正确 | 错误 | +|------|------|------| +| 字段名必须是注册过的**中文 label** | `国家包含('GB')` | `ConsigneeCountry=='GB'` | +| 字符串字段用 `字段包含('值')` | `客户包含('阿里')` | `客户()=='阿里'` | +| 数值/聚合用 `字段()` 再比较 | `实重()>5`、`单件最大长度()>60` | `实重>5`、`长度包含('60')` | +| 布尔用 `字段()==1` / `==0` | `是否偏远()==1` | `是否偏远()==true` | +| 字符串字面量单引号 | `'GB'`;内部单引号翻倍 `''` | `"GB"` | +| 逻辑运算 | `&&` `\|\|` `!` | `and` `or` `not` / 中文「且」 | +| 比较运算 | `==` `!=` `>` `>=` `<` `<=` | `=` `<>` | +| 算术 | `+ - * /` 与 `()` | — | +| 空公式 | 空串视为恒真(勿故意输出空) | — | +| 长度 | ≤ 10000 | — | + +`包含` 语义:字段字符串 **Contains** 参数(子串匹配,非全等)。多选可用 `\|\|`: + +```text +国家包含('GB')||国家包含('UK') +``` + +排除产品: + +```text +!销售产品包含('EX_ASP_HKDHL_YD') +``` + +## 写公式工作流 + +1. 从用户描述抽出:时机、操作、条件(国家/客户/产品/尺寸/重量/状态等)。 +2. 映射到**中文字段**(见下方常用表;完整目录见 [field-catalog.md](field-catalog.md))。 +3. 按字段类型选 `包含('')` 或 `字段()`。 +4. 用 `&&` / `||` / `!` / `()` 组合;复杂条件先括号。 +5. 对照 [examples.md](examples.md) 自检语法。 +6. 输出「时机 + 操作 + 公式 + 说明」;需要时给验证样例。 + +不确定字段名时:优先读 `formulaMeta.ts` 的 `ORDER_TABLE_FIELDS` / `VIRTUAL_FORMULA_FIELDS`,或后端 `AutoTriggerFormulaMeta`。**禁止编造未注册中文名**。 + +## 常用字段速查 + +### 聚合(下单尺寸最常用) + +| 中文 | 用法 | +|------|------| +| 单件最大长度 / 宽度 / 高度 | `单件最大长度()>60` | +| 单件长宽高之和 | `单件长宽高之和()>150` | + +### 订单 / 客户 + +| 中文 | 类型 | 典型 | +|------|------|------| +| 国家 | string | `国家包含('GB')`(二字码) | +| 收件国家名称 | string | `收件国家名称包含('英国')` | +| 销售产品 | string | `销售产品包含('EX_')` | +| 收货渠道 | string | `收货渠道包含('DHL')` | +| 客户 | string | `客户包含('阿里国际站')` | +| 客户组 | string | `客户组包含('VIP')` | +| 货物属性 | string | `货物属性包含('sensitive')` | +| 申报总金额 | number | `申报总金额()>1000` | +| 是否偏远 / 是否COD / 是否拦截 | boolean | `是否偏远()==1` | +| 实重 / 计费重 / 长度/宽度/高度 | number | `实重()>20`(件重尺;下单前优先用聚合字段) | + +### 收件人(选填场景) + +`收件人邮编包含('9')`、`收件人省州包含('CA')`、`收件人城市包含('London')` + +完整字段见 [field-catalog.md](field-catalog.md)。 + +## 时机与操作注意 + +- **下单 + 拦截**:建单前阻断(含草稿保存尝试)。 +- **下单 + 警告/问题件**:预报成功后触发;纯草稿保存不触发。 +- **入库/出库**:作业创建**之后**触发;「拦截」只打标,不回滚入库/出库。 +- **问题件**:需能匹配问题模板,否则可能静默跳过。 + +## 自检清单 + +- [ ] 每个字段都是注册中文名 +- [ ] 字符串用 `包含('…')`,数字用 `字段() 比较` +- [ ] 括号匹配;`&&`/`||` 优先级按需加括号 +- [ ] 国家一般用二字码 `国家包含('XX')` +- [ ] 排除条件用 `!字段包含('…')` +- [ ] 公式可单行粘贴进触发条件编辑器 + +## 更多 + +- 完整字段目录:[field-catalog.md](field-catalog.md) +- 场景示例:[examples.md](examples.md) diff --git a/.cursor/skills/oms-auto-trigger-formula/examples.md b/.cursor/skills/oms-auto-trigger-formula/examples.md new file mode 100644 index 0000000..084bd6d --- /dev/null +++ b/.cursor/skills/oms-auto-trigger-formula/examples.md @@ -0,0 +1,84 @@ +# 自动触发公式 · 场景示例 + +可直接粘贴到触发条件编辑器。按需改时机/操作。 + +## 1. 英国超尺寸拦截(官方示例) + +```text +时机:下单 +操作:拦截 +公式:(单件最大长度()>60||单件最大宽度()>46||((单件最大长度()+单件最大宽度()+单件最大高度())>150))&&国家包含('GB')&&客户包含('阿里国际站/深圳一达通')&&!销售产品包含('EX_ASP_HKDHL_YD') +``` + +验证样例:国家 `GB`,客户名含「阿里国际站」,长 70,宽 40,高 40 → 应命中;产品含 `EX_ASP_HKDHL_YD` → 不应命中。 + +## 2. 仅国家 + 超长 + +```text +(单件最大长度()>120)&&国家包含('US') +``` + +## 3. 多国家 + +```text +国家包含('GB')||国家包含('DE')||国家包含('FR') +``` + +## 4. 偏远警告 + +```text +时机:下单 +操作:警告 +公式:是否偏远()==1 +``` + +## 5. 高申报金额问题件 + +```text +时机:下单 +操作:问题件 +公式:申报总金额()>=2000&&国家包含('AU') +``` + +## 6. 敏感货 + 指定渠道 + +```text +货物属性包含('sensitive')&&收货渠道包含('DHL') +``` + +## 7. 客户组 VIP 且实重过大(入库后) + +```text +时机:入库 +操作:警告 +公式:客户组包含('VIP')&&实重()>30 +``` + +## 8. 排除某产品后的重量限制 + +```text +!销售产品包含('EX_LIGHT')&&计费重()>25 +``` + +## 9. 邮编前缀(子串) + +```text +国家包含('US')&&收件人邮编包含('90') +``` + +## 10. 组合排除 + +```text +国家包含('GB')&&!(销售产品包含('EX_A')||销售产品包含('EX_B')) +``` + +## 常见写错对照 + +| 错误 | 正确 | +|------|------| +| `国家=='GB'` | `国家包含('GB')` | +| `实重>5` | `实重()>5` | +| `客户包含("阿里")` | `客户包含('阿里')` | +| `是否偏远()==true` | `是否偏远()==1` | +| `国家包含(GB)` | `国家包含('GB')` | +| `长度()>60`(想表达单件最大) | `单件最大长度()>60` | diff --git a/.cursor/skills/oms-auto-trigger-formula/field-catalog.md b/.cursor/skills/oms-auto-trigger-formula/field-catalog.md new file mode 100644 index 0000000..717980b --- /dev/null +++ b/.cursor/skills/oms-auto-trigger-formula/field-catalog.md @@ -0,0 +1,154 @@ +# 自动触发公式 · 字段目录 + +与 `src/views/channel/autoTrigger/formulaMeta.ts` / `AutoTriggerFormulaMeta.cs` 对齐。 +**公式里只能用「中文」列**;英文列为库映射,禁止写进公式。 + +用法约定: + +- `string` / `datetime` → `中文包含('…')` +- `number` / 聚合 → `中文()` 再 `> >= < <= == !=` +- `boolean` → `中文()==1` 或 `==0` + +## 常用 / 聚合 `_virtual` + +| 中文 | 英文 Field | 类型 | +|------|------------|------| +| 单件最大长度 | PieceMaxLength | number | +| 单件最大宽度 | PieceMaxWidth | number | +| 单件最大高度 | PieceMaxHeight | number | +| 单件长宽高之和 | PieceSumLwh | number | + +## 订单 `t_order` + +| 中文 | Field | 类型 | +|------|-------|------| +| 请求标识 | RequestId | string | +| 运单号 | ServiceNo | string | +| 客户单号 | ClientNo | string | +| 跟踪号 | TrackNo | string | +| 销售产品 | ProductCode | string | +| 收货渠道 | ChannelCode | string | +| 下游服务商 | DownstreamSupplierCode | string | +| 下游AppKey | DownstreamAppKey | string | +| 下游AppId | DownstreamAppId | number | +| 渠道预报编码 | ChannelForecastCode | string | +| 国家 | ConsigneeCountry | string | +| 收件国家名称 | ConsigneeCountryName | string | +| 货物属性 | CargoAttribute | string | +| 出口报关方式 | ExportCustomsMode | string | +| 预报失败原因 | PredeclareFailReason | string | +| 规则版本号 | SalesProductRulesVersion | string | +| 价格版本号 | SalesProductPriceVersion | string | +| 订单状态 | OrderStatus | string | +| 处理子状态 | HandleStatus | string | +| 问题类型 | IssueType | string | +| 问题标题 | IssueTitle | string | +| 问题内容 | IssueContent | string | +| 问题模板 | IssueTemplateId | number | +| 问题备注 | IssueRemark | string | +| 问题件时间 | IssueTime | datetime | +| 理赔类型 | ClaimType | string | +| 索赔金额 | ClaimAmount | number | +| 索赔币种 | ClaimCurrency | string | +| 理赔原因 | ClaimReason | string | +| 理赔时间 | ClaimTime | datetime | +| 异常前状态 | PreviousOrderStatus | string | +| 仓库 | WarehouseId | number | +| 订单客户 | CustomerId | number | +| 组织 | OrganizationId | string | +| 密级 | SecretLevel | number | +| 发件人关联 | SenderId | number | +| 收件人关联 | RecipientId | number | +| 申报关联 | DeclarationId | number | +| 收货时间 | ReceiveTime | datetime | +| 发货时间 | ShipTime | datetime | +| 支付时间 | PayTime | datetime | +| 是否已支付 | IsPaid | boolean | +| 签收时间 | SignTime | datetime | +| 是否COD | IsCod | boolean | +| COD币种 | CodCurrency | string | +| COD金额 | CodSum | number | +| 申报币种 | DeclaredCurrency | string | +| 申报总金额 | DeclaredSum | number | +| 订单件数 | Quantity | number | +| 是否拦截 | IsIntercept | boolean | +| 是否偏远 | IsRemote | boolean | + +## 收件人 `t_order_recipient` + +| 中文 | Field | 类型 | +|------|-------|------| +| 收件人姓名 | RecipientsName | string | +| 收件人公司 | CompanyName | string | +| 收件人国家 | Country | number | +| 收件人手机 | Phone | string | +| 收件人电话 | TelePhone | string | +| 收件人邮编 | ZipCode | string | +| 收件人省州 | Province | string | +| 收件人城市 | City | string | +| 收件人门牌号 | StreetNumber | string | +| 收件人地址1 | StreetAddress1 | string | +| 收件人地址2 | StreetAddress2 | string | +| 收件人地址3 | StreetAddress3 | string | +| 收件人邮箱 | Email | string | +| 收件人税号类型 | TaxType | number | +| 收件人税号 | TaxNo | string | +| 收件人证件类型 | CertificateType | number | +| 收件人证件号 | CertificateNo | string | +| EORI | Eori | string | +| VAT | Vat | string | + +(若前端 label 与上表略有差异,以 `formulaMeta.ts` 为准。) + +## 发件人 `t_order_sender` + +以 `formulaMeta.ts` 中 `t_order_sender` 段为准(发件人姓名/公司/手机/地址等)。 + +## 申报 `t_order_declaration` + +常用:`申报品名`、`申报英文名`、`申报数量`、`申报单价`、`SKU`、`品牌`、`材质`、`原产国`、`是否带磁带电` 等——一律用文件内注册中文名。 + +## 件重尺 `t_order_weight` + +| 中文 | Field | 类型 | +|------|-------|------| +| 件重尺类型 | Type | string | +| 长度 | Length | number | +| 宽度 | Width | number | +| 高度 | Height | number | +| 实重 | ActualWeight | number | +| 体积重 | VolumetricWeight | number | +| 计费重 | ChargeableWeight | number | +| 体积 | Cubic | number | + +下单拦截优先用聚合「单件最大*」;入库/出库后可用实重/计费重。 + +## 费用 `t_order_fee` + +| 中文 | Field | 类型 | +|------|-------|------| +| 费用类型 | Type | string | +| 费用项 | CostItem | string | +| 费用金额 | Amount | number | +| 费用币种 | Currency | string | +| 费用下游服务商 | DownstreamProvider | string | +| 价格编号 | PriceNo | string | +| 价格生效时间 | PriceEffectiveTime | datetime | +| 计费阶段 | ChargingStage | string | + +## 客户 `t_customer` + +| 中文 | Field | 类型 | +|------|-------|------| +| 客户编码 | CustomerCode | string | +| 客户 | CustomerName | string | +| 客户邮箱 | Email | string | +| 客户手机 | Phone | string | +| 客户余额 | Balance | number | +| 客户总额度 | TotalQuota | number | +| 客户已用额度 | UsedQuota | number | +| 客户状态 | Status | string | +| 客户备注 | Remark | string | +| 销售 | Salesman | string | +| 客服 | CustomerService | string | +| 客户组 | CustomerGroup | string | diff --git a/docs/oms系统设计-050801.md b/docs/oms系统设计-050801.md index 8b9c4e6..8d340c2 100644 --- a/docs/oms系统设计-050801.md +++ b/docs/oms系统设计-050801.md @@ -108,9 +108,9 @@ OMS 业务表中的 **`CustomerId`** 指向 **`t_customer.Id`**(客户业务 | Excel 导入 | 解析后等同批量创建 / 异步 | **统一走** `t_order_batch_job`(`action=import-row`);MQ 开启异步入队,关闭则同步逐条处理;返回 **`jobId`** | | 批量重算费用 | `POST /order/fee/batch-recalculate` | **未实现**(§4.4.1.10 为设计稿) | | 订单支付 | 扣减 `t_customer.Balance` 并写 `t_customer_account_flow` | **已实现**:仅 `pendingPayment` 可支付;应收合计扣款;流水 `BizType=orderPay` | -| 面单 URL | 可选空 | 预报成功时生成 **HTML** 至 `/uploads/prints/orders/`,响应 **`labelUrl`** | +| 系统面单 / 订单附件 | 独立表;系统面单仅 1 份;客户只读系统面单 | **已实现**:`t_order_file`;创单成功后异步/同步生成 PDF 写入 **`systemLabel`**;打印优先读文件表,缺失则补生成;**已废弃** `t_order.LabelUrl` / `LabelGenerateStatus` / `LabelGenerateError` | | 规则快照下游元数据 | 排货 Provider 含渠道/itfx 对接字段 | **已实现**:规则发布时 **`AllocationProviderSnapshotEnricher`** enrich `Providers`;排货确认写入 **`t_order`** 四字段;**AppSecret 不落快照/订单** | -| 轨迹 / 下游预报 | 预报后异步轨迹;下游 createOrderOne | **部分**:预报成功异步 UpsertSmallBag;「我的订单」可手动 **`POST /order/track-upsert-small-bag`**;下游预报由前端调 itfx **`ForwardWithSubscribe`**,凭证优先读订单绑定字段 | +| 轨迹 / 下游预报 | 预报后异步轨迹;下游 createOrderOne | **部分**:预报成功异步 UpsertSmallBag;「我的订单」可手动 **`POST /order/track-upsert-small-bag`**;下游预报 **`POST /order/downstream-forward-create-one`**(及批量任务);成功回写 **`DownstreamServiceNo` / `TrackNo`**,下游面单写入 **`t_order_file.downstreamLabel`** | | 创建问题件 / 理赔件 | §4.4.1.13~§4.4.1.15 批量标记 + 问题模板消费 | **已落地**(`OrderService.Exception` + `OrderExceptionWorkflowService`) | | 问题件通知 | 按模板 `NotifyMethod` 投递 | **部分**:`inApp`(站内信)、`email`(操作人 SMTP)已接入;`sms/feishu/wechat` 待通道对接 | | 自动触发运行时 | 下单/入库/出库消费 `t_auto_trigger` | **部分**:`AutoTriggerRuntimeService` 已挂钩;**问题件**联动问题模板,**拦截/警告**已支持 | @@ -145,12 +145,13 @@ OMS 业务表中的 **`CustomerId`** 指向 **`t_customer.Id`**(客户业务 1. **查价**(可选)→ **创建 / 预报** → 校验 → 草稿与包裹 → **预报计重完成后**将全部启用卖价项写入 **`t_order_fee`**(`ChargingStage=forecast`)。 2. **契约固化**:预报等关键节点写入 **`SalesProductRulesVersion`、`SalesProductPriceVersion`**(及渠道侧应付相关版本,若有);非显式「改单重算」流程 **不得** 因后台新发布而改写已存版本号。 -3. **状态机**(概念顺序;枚举见 **`## 10 附录` → `### 10.3`**):如 草稿 → 已预报 → **待排货(可选)** → 待支付 → 待发货 → **待上网** → 运输中 → 派送成功/失败;另有拦截、**问题件**(`problematic`)、**理赔件**(`claimed`)、删除等分支。入库排货失败时进入 **`pendingAllocation`**,写入 **`AllocationPendingReason`**,仍保留收货与 inbound 应收。 -4. **支付**:OMS 侧订单状态与支付动作对接;**扣减 `t_customer.Balance`** 并 **写入 `t_customer_account_flow`(`BizType=orderPay`,金额为负)**;支付金额取订单 **`t_order_fee` 应收合计**。 -5. **分阶段计费(按件重尺类型)**:卖价/买价 **不再配置「收费时机」**;应收在 **`t_order_weight.Type=forecast` 或 `receive` 计重完成** 后触发,全部启用价格项参与;`t_order_fee.ChargingStage` 标记本次算价所用重量阶段(`forecast` / `inbound`)。出库阶段(`ship` 行)待 §6.3.2.1 接入。 -6. **批量下单**:Excel/模板 或 JSON 批量接口 → 异步任务;**单次最多受理 2000 条**。 -7. **揽收订单**(§4.4.4):客户维护 **发件地址簿**(§4.2.10 / §4.4.5)→ 创建时选 **发件地址 + 仓库 + 运输方式(销售产品)+ 揽收时间 + 货物名称/重量** → 服务端组装为小包创建入参 → **复用 `CreateParcelCoreAsync` / §4.4.3.1** 落 `t_order`(`OrderSource=pickup`)。**不是**仓储模块 `C_parcel` / 到货登记另开一套订单主表。 -8. **店铺订单创建物流**(§4.4.7):平台店铺单勾选 → 校对收件人/明细 → 选运输方式 → **批量创建** `t_order`(`OrderSource=platform`)并回写 `LinkedOrderId`;若「物流&代发」则同弹窗内按单绑定包裹/库存(复用 §4.4.6 语义)。与「匹配库存」分流:后者依赖平台 SKU 映射并创建时扣减库存。 +3. **系统面单**:创单进入 **`predeclared`** 后触发生成(队列开启则异步 `generate-label-pdf`,否则同步);PDF 落盘并写入 **`t_order_file`(`FileType=systemLabel`,仅 1 份可替换)**;失败**不阻断创单**,打印时补生成。模板解析顺序:订单 **`LabelTemplateId`** → 销售产品绑定模板 → 全局默认模板。 +4. **状态机**(概念顺序;枚举见 **`## 10 附录` → `### 10.3`**):如 草稿 → 已预报 → **待排货(可选)** → 待支付 → 待发货 → **待上网** → 运输中 → 派送成功/失败;另有拦截、**问题件**(`problematic`)、**理赔件**(`claimed`)、删除等分支。入库排货失败时进入 **`pendingAllocation`**,写入 **`AllocationPendingReason`**,仍保留收货与 inbound 应收。 +5. **支付**:OMS 侧订单状态与支付动作对接;**扣减 `t_customer.Balance`** 并 **写入 `t_customer_account_flow`(`BizType=orderPay`,金额为负)**;支付金额取订单 **`t_order_fee` 应收合计**。 +6. **分阶段计费(按件重尺类型)**:卖价/买价 **不再配置「收费时机」**;应收在 **`t_order_weight.Type=forecast` 或 `receive` 计重完成** 后触发,全部启用价格项参与;`t_order_fee.ChargingStage` 标记本次算价所用重量阶段(`forecast` / `inbound`)。出库阶段(`ship` 行)待 §6.3.2.1 接入。 +7. **批量下单**:Excel/模板 或 JSON 批量接口 → 异步任务;**单次最多受理 2000 条**。 +8. **揽收订单**(§4.4.4):客户维护 **发件地址簿**(§4.2.10 / §4.4.5)→ 创建时选 **发件地址 + 仓库 + 运输方式(销售产品)+ 揽收时间 + 货物名称/重量** → 服务端组装为小包创建入参 → **复用 `CreateParcelCoreAsync` / §4.4.3.1** 落 `t_order`(`OrderSource=pickup`)。**不是**仓储模块 `C_parcel` / 到货登记另开一套订单主表。 +9. **店铺订单创建物流**(§4.4.7):平台店铺单勾选 → 校对收件人/明细 → 选运输方式 → **批量创建** `t_order`(`OrderSource=platform`)并回写 `LinkedOrderId`;若「物流&代发」则同弹窗内按单绑定包裹/库存(复用 §4.4.6 语义)。与「匹配库存」分流:后者依赖平台 SKU 映射并创建时扣减库存。 ### 2.4 主流程 C:操作(仓库) @@ -316,7 +317,7 @@ JWT → mapId(user.userid INT) | 单条读 | 按 `id` 加载后校验 `t_order.CustomerId == effectiveCustomerId`,否则按 **「订单不存在」** 返回(**禁止**泄露他客订单存在性) | | 新增(C) | 创建订单/批量任务时 **`CustomerId` 仅取自会话**,写入 `t_order.CustomerId` / `t_order_batch_job.CustomerId`;请求体若带 `customerId` 须与 `effectiveCustomerId` **一致**,否则 **403** | | 更新/删除(U/D) | 支付、预报、拦截、删除、打印、费用重算等:先 **归属校验** 再执行业务;批量接口逐单校验,任一不属于当前客户则该单失败或整批拒绝(实现二选一须在 **§4.4.0** 写死) | -| 关联表 | `t_order_sender`、`t_order_recipient`、`t_order_declaration`、`t_order_weight`、`t_order_fee` 等通过 **`OrderId`** 间接隔离,**禁止**仅凭子表 Id 绕过主表 `CustomerId` 校验;**`t_customer_sender_address`** 以 **`CustomerId`** 直接隔离(§4.2.10) | +| 关联表 | `t_order_sender`、`t_order_recipient`、`t_order_declaration`、`t_order_weight`、`t_order_fee`、**`t_order_file`** 等通过 **`OrderId`** 间接隔离,**禁止**仅凭子表 Id 绕过主表 `CustomerId` 校验;**`t_customer_sender_address`** 以 **`CustomerId`** 直接隔离(§4.2.10) | | 内部用户 | `loginType=0` **默认禁止**调用 `/admin/order/*` 客户订单接口;若运营后台需跨客户查单,须 **另设** 内部管理路径与 PermissionCode,**不得**复用客户侧 `/admin/order` 且不带客户隔离 | > **安全原则**:`CustomerId` **不得**由前端单独作为可信事实;**不得**仅按订单 `Id` 操作而不校验归属。 @@ -326,7 +327,7 @@ JWT → mapId(user.userid INT) ### 4.1 核心需求说明 -- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出;**创建问题件**、**创建理赔件**、处理子状态筛选;可按 **`OrderSource`**(含揽收)筛选;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败→问题件→理赔件等全生命周期。 +- **我的订单**:支持订单全流程管理,包括分页查询、详情查看、支付、预报、拦截/取消拦截、删除、打印、导出、**订单附件**(§4.2.12 / §4.4.1.17);**创建问题件**、**创建理赔件**、处理子状态筛选;可按 **`OrderSource`**(含揽收)筛选;订单状态覆盖草稿→已预报→已收货→待支付→待发货→运输中→派送成功/失败→问题件→理赔件等全生命周期。**客户门户**附件仅可查看 **系统面单**,不可上传/删除。 - **我要查价**:根据目的国、运输方式、重量尺寸、货物类型等条件查价,返回可选渠道及费用明细;与正式下单共用「选版本 + 内存匹配」计费逻辑,响应宜携带 `rulesVersion` / `priceVersion` 便于与订单对齐。 - **我要下单**:支持下载下单模板、上传 Excel 批量下单、在 **批量任务** 页查看导入进度与失败明细;模板分为标准模板与自定义模板。**单次导入订单上限 2000 条**(Excel 有效数据行或 `/order/parcel/batch-create` 请求数组长度,超出拒绝)。 - **发件地址**(菜单挂 **客户**,接口属订单域):客户维护可复用的发件地址簿,供揽收下单选择;**不**写入订单快照表 `t_order_sender`(见 §4.2.10 / §4.4.5)。 @@ -345,6 +346,7 @@ JWT → mapId(user.userid INT) | ServiceNo | VARCHAR | 64 | - | 是 | 运单号 | | ClientNo | VARCHAR | 64 | - | 否 | 客户单号 | | TrackNo | VARCHAR | 64 | - | 否 | 跟踪号 | +| DownstreamServiceNo | VARCHAR | 64 | - | 否 | 服务商单号(下游 createOrderOne 成功响应 **`Data.serviceNo`**);OMS 自有运单号仍为 **`ServiceNo`** | | ProductCode | VARCHAR | 128 | - | 否 | 产品code | | ChannelCode | VARCHAR | 128 | - | 否 | 渠道code | | DownstreamSupplierCode | VARCHAR | 64 | - | 否 | 下游 API 服务商编码(itfx **`supplierCode`**);排货确认渠道时自规则快照 **`Providers`** 写入 | @@ -389,12 +391,15 @@ JWT → mapId(user.userid INT) | AllocationPendingReason | VARCHAR | 512 | - | 否 | 待排货原因:入库排货或渠道买价未匹配时记录(`OrderStatus=pendingAllocation`) | | SalesProductRulesVersion | VARCHAR | 32 | - | 否 | 下单绑定的规则版本号(与发布记录 `Version` 一致,`OperatorType=rules`) | | SalesProductPriceVersion | VARCHAR | 32 | - | 否 | 下单绑定的价格版本号(与发布记录 `Version` 一致,`OperatorType=price`) | +| LabelTemplateId | INT | - | 外键 → `t_label_template.Id` | 否 | 出单实际使用的标签模板 Id(模板配置,**非**面单文件地址;文件见 **`t_order_file.systemLabel`**) | | IsDeleted | TINYINT | - | - | 是 | 是否删除:0-否,1-是,默认0 | | CreateUserId | INT | - | - | 是 | 创建人 | | CreateTime | DATETIME | - | - | 是 | 创建时间 | | UpdateUserId | INT | - | - | 否 | 更新人 | | UpdateTime | DATETIME | - | - | 否 | 更新时间 | +> **已废弃字段(勿再写入)**:原 `LabelUrl` / `LabelGenerateStatus` / `LabelGenerateError` 已迁至 **`t_order_file`**(见 **§4.2.12**),迁移脚本删除订单侧列。 + **OrderStatus 枚举**:draft, predeclared, pendingAllocation, received, pendingPayment, pendingShip, pendingOnline, shipping, deliverySuccess, deliveryFailed, problematic, deleted, claimed **OrderSource 枚举**:见 **§10.3.9**(`manual` / `pickup` / `upload` / `batch` / `platform`) @@ -721,6 +726,47 @@ JWT → mapId(user.userid INT) 4. 已上架包裹行(有 `SkuId`)绑定包裹时**同时**冻结对应库存,避免重复售卖。 5. 保存采用**全量覆盖**:提交 `items[]` 为最终态。 +#### 4.2.12 订单附件表 `t_order_file` + +> **用途**:订单级附件(交易证明、系统面单、下游面单、末端面单、红外图片)统一落库;**不再**在 `t_order` 上存面单 URL / 生成状态字段。 +> **系统面单**:`FileType=systemLabel` **仅允许 1 份**;生成或上传时先软删同类型旧记录再插入(替换)。其它类型每类最多 **20** 份。 +> **客户 ACL**:客户登录仅可 **查询** `systemLabel`;**禁止**上传/删除任意附件类型。内部账号可管理全部类型。 +> **迁移脚本**:`sql/20260728_order_file.sql`(建表 + 迁移旧 `LabelUrl` → `systemLabel` + 删除订单旧列);注释补丁 `sql/20260728_order_file_comments.sql`。 + +| 字段名 | 字段类型 | 长度 | 主键/外键 | 非空 | 说明 | +| --- | --- | --- | --- | --- | --- | +| Id | INT | - | 主键 | 是 | 自增 | +| OrderId | INT | - | 外键 → `t_order.Id` | 是 | 订单 Id | +| FileType | VARCHAR | 32 | - | 是 | 附件类型:`tradeProof` 交易证明 / `systemLabel` 系统面单 / `downstreamLabel` 下游面单 / `lastMileLabel` 末端面单 / `infraredImage` 红外图片 | +| FileName | VARCHAR | 255 | - | 否 | 原始文件名 | +| FileUrl | VARCHAR | 512 | - | 是 | 文件地址(相对路径如 `/uploads/...` 或外链) | +| ContentType | VARCHAR | 128 | - | 否 | 文件 MIME 类型 | +| FileSize | BIGINT | - | - | 否 | 文件大小(字节) | +| Sort | INT | - | - | 是 | 同类型内排序,默认 0 | +| Source | VARCHAR | 32 | - | 是 | 来源:`upload` 上传 / `systemGenerate` 系统生成 / `downstream` 下游回传 / `migrate` 迁移;默认 `upload` | +| Remark | VARCHAR | 512 | - | 否 | 备注 | +| IsDeleted | TINYINT | - | - | 是 | 软删除:0-否,1-是,默认 0 | +| ConcurrencyStamp | CHAR | 36 | - | 是 | 并发戳;见 **§3.1.2**,默认 UUID | +| CreateUserId | INT | - | - | 是 | 创建人 | +| CreateTime | DATETIME | - | - | 是 | 创建时间 | +| UpdateUserId | INT | - | - | 否 | 更新人 | +| UpdateTime | DATETIME | - | - | 否 | 更新时间 | + +**索引** + +| 索引名 | 类型 | 字段 | 说明 | +| --- | --- | --- | --- | +| idx_ord_file_order_type | KEY | OrderId, FileType, IsDeleted | 按订单+类型查询 | + +**写入约定** + +| 场景 | FileType | Source | 说明 | +| --- | --- | --- | --- | +| 创单成功 / 打印补生成 / 批量 `generate-label-pdf` | `systemLabel` | `systemGenerate` | `LabelPdfGenerationService`;替换旧系统面单 | +| 内部上传 | 任一类型 | `upload` | `POST /order/files/upload`;`systemLabel` 仍仅 1 份 | +| 下游 createOrderOne 成功返回 `labelUrl` | `downstreamLabel` | `downstream` | 不写 `t_order`;与 `DownstreamServiceNo`/`TrackNo` 一并回写 | +| 历史 `t_order.LabelUrl` 迁移 | `systemLabel` | `migrate` | 一次性 SQL | + ### 4.3 下单计费与版本策略(配置 → 运行时 → 下单) > 本节约定**销售产品**维度的规则/卖价如何进入线上算价链路,以及与 `t_order` 版本字段的对应关系;与渠道模块中「运营发布 → 生成版本」接口衔接。 @@ -858,7 +904,7 @@ JWT → mapId(user.userid INT) | **增** | `POST /order/parcel/create`、`POST /order/parcel/batch-create`、`POST /order/upload` | 落库 `CustomerId` **仅**取 `effectiveCustomerId`;请求体 `customerId` 若存在须 **相等**,否则拒绝 | | **改** | `POST /order/pay`、`POST /order/pre-declare`、`POST /order/intercept`、`POST /order/cancel-intercept`、`POST /order/fee/batch-recalculate` | 按 `id` / `orderIds` 加载订单后 **逐单**校验 `CustomerId`;不属于当前客户 **不得**变更状态或费用 | | **删** | `POST /order/delete` | 同上,仅允许删除 **`CustomerId = effectiveCustomerId`** 的订单 | -| **其它写** | `POST /order/print`、`POST /order/export` | 打印/导出前校验归属;导出筛选范围 **限定**当前客户 | +| **其它写** | `POST /order/print`、`POST /order/export`、`GET/POST /order/files*` | 打印/导出/附件前校验归属;导出筛选范围 **限定**当前客户;客户附件仅可读 systemLabel | **批量与关联资源** @@ -898,14 +944,17 @@ JWT → mapId(user.userid INT) | 拦截 | POST | /order/intercept | 订单拦截;**须校验订单归属** | | 取消拦截 | POST | /order/cancel-intercept | 取消订单拦截;**须校验订单归属** | | 删除 | POST | /order/delete | 删除订单;**须校验订单归属** | -| 打印 | POST | /order/print | 打印订单;**须校验订单归属** | +| 打印 | POST | /order/print | 打印订单;优先返回 **`t_order_file.systemLabel`**,缺失则补生成 PDF;**须校验订单归属** | +| 订单附件列表 | GET | /order/files | 按类型分组;客户仅返回 **systemLabel**;**须校验订单归属** | +| 上传订单附件 | POST | /order/files/upload | multipart;客户账号 **禁止**;**须校验订单归属** | +| 删除订单附件 | POST | /order/files/delete | 客户账号 **禁止**;**须校验订单归属** | | 导出 | POST | /order/export | 导出订单;**仅导出当前客户订单** | | 批量重算费用 | POST | /order/fee/batch-recalculate | 入参订单 Id 列表 + `scopes`;**逐单校验 `CustomerId`**(§4.4.0) | | 创建轨迹订单 | POST | /order/track-upsert-small-bag | 手动同步调用轨迹 **UpsertSmallBag**(联调/补推);**须校验订单归属** | | 创建问题件 | POST | /order/mark-problematic | 批量将订单标记为 **问题件**;消费问题模板(§5.2.21);**须校验订单归属** | | 创建理赔件 | POST | /order/mark-claimed | 批量将订单标记为 **理赔件**;**须校验订单归属** | | 更新处理子状态 | POST | /order/handle-status/update | 问题件/理赔件 **待处理→已处理**;**须校验订单归属** | -| 预报下游订单 | —(前端) | itfx **`POST /InterfaceForward/ForwardWithSubscribe`** | 「我的订单」批量操作;`standardCode=createOrderOne`;凭证优先读订单下游绑定字段(§4.4.1.12) | +| 预报下游订单 | POST | /order/downstream-forward-create-one | OMS 组装 createOrderOne 并转发 itfx;成功回写服务商单号/跟踪号,下游面单入 **`t_order_file`**(§4.4.1.12) | ##### 4.4.1.1 分页查询订单列表(POST /order/list) @@ -1332,22 +1381,27 @@ JWT → mapId(user.userid INT) "code": 0, "msg": "success", "data": { - "printUrl": "" + "printUrl": "/uploads/prints/orders/....pdf", + "labelGenerateStatus": "Ready", + "labelGenerateError": null } } ``` +> `labelGenerateStatus` / `labelGenerateError` 为 **本次响应瞬时字段**(Ready / Failed),**不落库**。 + ###### 2 接口业务逻辑 ###### 核心流程 1. `GetOrderOrThrowAsync(id)` → 映射订单详情。 -2. `CreateOrderPrintFileAsync`:生成 **HTML** 面单至 `wwwroot/uploads/prints/orders/{serviceNo}-{timestamp}.html`。 -3. 返回 **`printUrl`**(相对路径,如 `/uploads/prints/orders/...`)。 +2. 若已存在 **`t_order_file`(`FileType=systemLabel`)** 有效记录,直接返回其 **`FileUrl`** 作为 `printUrl`。 +3. 否则调用 **`LabelPdfGenerationService.GenerateAndPersistAsync`**:按模板渲染 PDF → 落盘 `/uploads/prints/orders/` → **替换写入** `systemLabel`;成功返回 PDF 相对路径。 +4. PDF 仍失败时:回落 `CreateOrderPrintFileAsync` 生成简易 **HTML** stub,避免彻底不可打;响应带失败原因。 ###### 3 当前实现(oms0414-back) -- `OrderService.PrintAsync` + `OrderService.Helpers.CreateOrderPrintFileAsync`(简易 HTML,非渠道标签模板)。 +- `OrderService.PrintAsync` + `OrderFileManager` + `LabelPdfGenerationService`;批量打印走 `PrintForBatchAsync` / 批量任务 `action=print`。 ##### 4.4.1.9 导出(POST /order/export) @@ -1481,11 +1535,11 @@ JWT → mapId(user.userid INT) 4. 轨迹凭证读 **`t_system_other_config`**(`ConfigCode=track_service`),缺省回退 **`Oms:TrackService`**(§8.2.7);未启用或参数不全时返回 **`skipped=true`** 及原因。 5. 联调入口:**`POST /order/debug/track-upsert-small-bag`**(Swagger,可自定义测试字段)。 -##### 4.4.1.12 预报下游订单(前端 → itfx,非 OMS 后端接口) +##### 4.4.1.12 预报下游订单(POST /order/downstream-forward-create-one) -> **实现状态:已落地**(前端 `order/myOrders` 批量操作 + `forwardCreateOrderOne`;凭证解析 `resolveOrderDownstreamCredentials`)。 +> **实现状态:已落地**(OMS 后端转发 itfx;亦可批量任务 `downstream-forward-create-one`)。 -运营在「我的订单」勾选订单后,浏览器直连 itfx 网关 **`POST /InterfaceForward/ForwardWithSubscribe`**,**`standardCode=createOrderOne`**,Body 为 **§4.4.3.1** 同构的 parcel/create JSON(`orderCreateOneBody.ts` 由订单详情组装)。 +运营在「我的订单」勾选订单后,由 **OMS** 组装 createOrderOne Body 并调用 itfx 下游;凭证优先读订单绑定字段(§4.3.4.2)。 ###### 1 凭证解析优先级 @@ -1496,11 +1550,13 @@ JWT → mapId(user.userid INT) **AppSecret** 不落订单、不落规则快照;无论哪条路径,均按 **`downstreamAppId`(或分组 `AppId`)** 调 itfx **`GET /App/GetInfo`** 读取。 -###### 2 请求要点 +###### 2 成功回写 -- Query:`standardCode=createOrderOne`,`supplierCode={DownstreamSupplierCode}`。 -- Header:`appkey`、`appSecret`、`followName=0`(itfx 网关约定)。 -- Body:camelCase,与 **§4.4.3.1** 创建小包请求体对齐(含 `receiver`、`items` 等)。 +| 下游 Data 字段 | 落库位置 | +| --- | --- | +| `serviceNo` | **`t_order.DownstreamServiceNo`**(OMS 自有运单号 `ServiceNo` 不变) | +| `trackNo` | **`t_order.TrackNo`**(空值不覆盖已有) | +| `labelUrl` | **`t_order_file`**,`FileType=downstreamLabel`,`Source=downstream`(**不写**订单主表) | ###### 3 与规则快照的关系 @@ -1669,6 +1725,7 @@ JWT → mapId(user.userid INT) | 场景 | UI | | --- | --- | +| 订单详情抽屉 | Tab **附件**(§4.4.1.17):内部可上传各类型;客户门户仅展示系统面单只读 | | 非问题件/理赔件 Tab | 工具栏 **创建问题件**、**创建理赔件**(须勾选行;`hasPermission` 控制) | | 问题件/理赔件 Tab | 子 Tab **待处理 / 已处理**(已落地);工具栏 **标记已处理**(`Order.HandleStatus.Update`) | | 创建问题件弹窗 | `ModalPro` + `FormPro`:问题类型 → 联动 **`issue-template/options`** → 可选模板;标题、内容可编辑;备注 | @@ -1677,6 +1734,29 @@ JWT → mapId(user.userid INT) 列表/详情响应补充字段(实现时):`handleStatus`、`handleStatusName`、`issueType`、`issueTitle`、`claimType`、`claimAmount` 等(与 **§4.2.1** 对齐)。 +##### 4.4.1.17 订单附件(GET/POST /order/files*) + +> **实现状态:已落地**(`OrderService.OrderFiles` + `OrderFileManager`;前端订单详情抽屉「附件」Tab)。 + +###### 1 查询(GET /order/files?orderId=) + +- 校验订单归属。 +- 返回按 `FileType` 分组的附件列表;**客户登录仅含 `systemLabel`**,其它类型键可不返回或为空数组。 + +###### 2 上传(POST /order/files/upload,multipart) + +- 表单:`orderId`、`fileType`、`files`。 +- **客户账号禁止**;内部账号校验订单归属后写入。 +- `systemLabel` 仅允许 1 个文件(替换旧记录);其它类型合计不超过 20。 + +###### 3 删除(POST /order/files/delete) + +```json +{ "id": 1 } +``` + +- **客户账号禁止**;软删 `t_order_file` 行。 + #### 4.4.2 我要查价 | 接口名称 | 请求方式 | 请求路径 | 说明 | @@ -2074,11 +2154,12 @@ JWT → mapId(user.userid INT) - 更新 `t_order.OrderStatus = 'predeclared'` - 更新 `t_order.PredeclareFailReason = ''` - 更新 `t_order.UpdateUserId`、`t_order.UpdateTime` + - **系统面单**:调用 `EnqueueGenerateLabelPdfAsync`(MQ 开启则入队 `generate-label-pdf`,否则同步生成);写入 **`t_order_file.systemLabel`**;失败不阻断创单。 - 返回字段: - `clientNo`:取 `t_order.ClientNo` - `serviceNo`:取 `t_order.ServiceNo` - `trackNo`:取 `t_order.TrackNo` - - `labelUrl`:若当前流程未生成面单则返回空字符串 + - `labelUrl`:取已生成的 **系统面单** `FileUrl`;若尚未就绪则为空字符串(异步队列场景常见) ###### 状态与失败处理 @@ -2106,7 +2187,7 @@ JWT → mapId(user.userid INT) | `serviceNo` | 自定义时查重,冲突 → **「运单号已存在」**;否则 **`IDE{yyMMddHHmmss}{4位随机}`** | | 快照 | 见 **§4.3.5**(DB 发布 + Live 降级,**无 Redis**) | | 应付/排货 | **创建时不执行**;入库时 **TryConfirm**(失败 → `pendingAllocation`) | -| 成功 | `OrderStatus=predeclared`;清空 `AllocationPendingReason`;**`labelUrl`** 同 print 逻辑生成 HTML | +| 成功 | `OrderStatus=predeclared`;清空 `AllocationPendingReason`;触发系统面单生成写入 **`t_order_file.systemLabel`**;响应 **`labelUrl`** 读文件表(未就绪可为空) | | 失败 | 草稿已建则 **`MarkParcelDraftFailedAsync`**(保持 `draft` + `PredeclareFailReason`)后 **仍抛业务异常** | ##### 4.4.3.2 批量创建小包(POST /order/parcel/batch-create) @@ -8424,6 +8505,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20 | 迭代日期 | 迭代编号 | 迭代功能 | 变更模块 | 备注 | | --- | --- | --- | --- | --- | +| 2026-07-28 | 20260728-01 | 订单附件表 + 系统面单落文件表 | 订单 §1.5.4、§2.3、§4.1、§4.2.1、**§4.2.12**、§4.4.1、§4.4.1.8、§4.4.1.12、§4.4.1.17、§4.4.3.1、§10.6.1 | `t_order_file`;废弃订单 `LabelUrl`/`LabelGenerate*`;客户只读 systemLabel;下游 labelUrl→downstreamLabel | | 2026-07-22 | 20260722-01 | 查价/下单/入库/出库链路加固;待排货;运单号 IDE | 订单 §2.3、§4.3.5、§4.4.2.1、§4.4.3.1、§6.3;附录 §10.3.1;新手指引 `/order/guide` | 查价可选尺寸+参考价;创建仅 forecast 应收;入库可选排货→`pendingAllocation`;运单号 `IDE{yyMMddHHmmss}{4位随机}` | | 2026-07-19 | 20260719-01 | 店铺订单创建物流 / 物流&代发 | 订单 §4.1、§2.3、§4.2.11、**§4.4.7**;附录权限 | 校对弹窗;`batch-create-logistics` / `batch-create-fulfill`;与匹配库存分流 | | 2026-07-15 | 20260715-01 | 客户发件地址 + 揽收订单 | 订单 §4.1、§4.2.1、§4.2.10、§4.4.1.1、§4.4.4、§4.4.5;附录 §10.3.9 | 地址簿 `t_customer_sender_address`;`OrderSource=pickup` 复用小包创建;菜单客户/订单 | @@ -8769,7 +8851,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20 - 查价 **`failures[].step`**:`product` / `snapshot` / `blacklist` / `weight` / `zone` / `price` - 充值审核 **`CustomerBalanceMutator.CreditRechargeAsync`**(`Pending` 可审,**Balance 与 `t_customer_account_flow` 同事务**) - 订单支付 **`CustomerBalanceMutator.DebitOrderPayAsync`**(见 **§4.4.1.3、§7.4**) -- 面单 **`labelUrl` / `printUrl`**:HTML 文件 `/uploads/prints/orders/` +- 面单 **`labelUrl` / `printUrl`**:优先 **`t_order_file.systemLabel`**(PDF);缺失时打印可补生成或回落 HTML stub `/uploads/prints/orders/` --- @@ -8781,7 +8863,7 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20 | 模块 | 网关前缀(生产) | 服务内路径 | 示例 | | --- | --- | --- | --- | -| 订单 | `/admin/order/` | `/order/` | list, detail, pay, pre-declare, cancel-intercept, delete, price-check, parcel/create, parcel/batch-create;**设计中**:fee/batch-recalculate, mark-problematic, mark-claimed, handle-status/update | +| 订单 | `/admin/order/` | `/order/` | list, detail, pay, pre-declare, cancel-intercept, delete, print, files, files/upload, files/delete, price-check, parcel/create, parcel/batch-create, downstream-forward-create-one;**设计中**:fee/batch-recalculate, mark-problematic, mark-claimed, handle-status/update | | 渠道-销售产品 | `/admin/channel/`(同订单网关) | `/channel/sales-product/` | list, detail, create, update, config/save, delete, price/list, price/save, allocation/get, publish/preview | | 渠道-服务渠道 | `/admin/channel/` | `/channel/service-channel/` | list, detail, create, update, config/save, delete, group/list, price/save, publish/preview, publish/rollback | | 渠道-计重 | `/admin/channel/` | `/channel/weight-rule/`、`/channel/weight-rounding/` | list, create, update, delete, info/list, info/add | @@ -8928,7 +9010,10 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20 | POST | `/order/intercept` | 已落地 | `IsIntercept=1` | | POST | `/order/cancel-intercept` | 已落地 | `IsIntercept=0` | | POST | `/order/delete` | 已落地 | 软删 + `deleted` | -| POST | `/order/print` | 已落地 | HTML 面单 URL | +| POST | `/order/print` | 已落地 | 优先 `t_order_file.systemLabel`;缺失补生成 PDF | +| GET | `/order/files` | 已落地 | 按类型分组;客户仅 systemLabel | +| POST | `/order/files/upload` | 已落地 | 内部上传;客户禁止 | +| POST | `/order/files/delete` | 已落地 | 软删;客户禁止 | | POST | `/order/export` | 已落地 | Excel,24h 过期 | | POST | `/order/price-check` | 已落地 | 见 §4.3.5、§4.4.2.1 | | GET | `/order/template/list` | 已落地 | 客户见 standard + 自有 custom | @@ -8939,8 +9024,10 @@ GET /customer/account-flow/list?page=1&pageSize=50&bizType=orderPay&startTime=20 | GET | `/order/batch/jobs/{id}/items` | 已落地 | 子项明细(含失败行、客户单号) | | POST | `/order/batch/jobs/{id}/retry-failed` | 已落地 | 重试失败子项 | | POST | `/order/batch/jobs/{id}/cancel` | 已落地 | 取消待处理子项 | -| POST | `/order/parcel/create` | 部分 | 卖价预报应收;无应付;Live 快照 | +| POST | `/order/parcel/create` | 部分 | 卖价预报应收;无应付;Live 快照;成功后生成 systemLabel | | POST | `/order/parcel/batch-create` | 部分 | 同步批处理,见 §4.4.3.2 | +| POST | `/order/downstream-forward-create-one` | 已落地 | itfx createOrderOne;回写 DownstreamServiceNo/TrackNo + downstreamLabel | +| GET | `/order/downstream-create-one-body` | 已落地 | 预览下游请求体 | | POST | `/order/fee/batch-recalculate` | 设计中 | §4.4.1.10 | | POST | `/order/track-upsert-small-bag` | 已落地 | 手动轨迹 UpsertSmallBag;见 **§4.4.1.11** | | POST | `/order/mark-problematic` | 设计中 | §4.4.1.13;批量标记问题件 + 问题模板 | diff --git a/src/api/channel/autoTrigger.ts b/src/api/channel/autoTrigger.ts index e119114..a30bff4 100644 --- a/src/api/channel/autoTrigger.ts +++ b/src/api/channel/autoTrigger.ts @@ -55,10 +55,12 @@ export async function testAutoTriggerFormula(body: { } }) { const res = await axiosInstance.post(omsUrl('/channel/auto-trigger/test'), body) - return unwrapData(res.data) as { - matched: boolean - expression?: string - contextType?: string - error?: string + assertBusinessOk(res.data) + const data = unwrapData(res.data) as Record + return { + matched: Boolean(data?.matched ?? data?.Matched), + expression: data?.expression ?? data?.Expression, + contextType: data?.contextType ?? data?.ContextType, + error: data?.error ?? data?.Error, } } diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 48cc190..5b8c9cf 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -3,6 +3,7 @@ */ import { axiosInstance } from '@/api/rbac' import { resolveAdminApiPath } from '@/utils/adminApi' +import { extractDraftFailFromBody, parseOmsDraftMarker } from '@/utils/parcelCreateFailGuide' function orderUrl(path: string): string { return resolveAdminApiPath(path) @@ -203,8 +204,23 @@ export function postOrderPay(body: OrderIdRequest) { } /** POST /order/pre-declare */ -export function postOrderPreDeclare(body: OrderIdRequest) { - return postOrderById('/order/pre-declare', body) +export async function postOrderPreDeclare(body: OrderIdRequest): Promise { + try { + const res = await axiosInstance.post(orderUrl('/order/pre-declare'), { id: body.id }) + const resBody = res.data + if (!isBusinessOkBody(resBody)) { + throwParcelCreateBusinessError(resBody, '预报失败') + } + const extracted = extractDraftFailFromBody(resBody) + if (extracted.meta?.draftSaved) { + throwParcelCreateBusinessError(resBody, extracted.cleanMessage || '预报失败') + } + } catch (e: any) { + if (e instanceof ParcelCreateBusinessError) throw e + const data = e?.response?.data + if (data) throwParcelCreateBusinessError(data, e?.message || '预报失败') + throw e + } } /** POST /order/intercept */ @@ -1576,11 +1592,84 @@ export async function postOrderUpload( } } +/** 小包创建/预报业务失败(可能已落草稿) */ +export class ParcelCreateBusinessError extends Error { + draftSaved?: boolean + orderId?: number + serviceNo?: string + raw?: any + + constructor( + message: string, + extra?: { + draftSaved?: boolean + orderId?: number + serviceNo?: string + raw?: any + }, + ) { + super(message) + this.name = 'ParcelCreateBusinessError' + this.draftSaved = extra?.draftSaved + this.orderId = extra?.orderId + this.serviceNo = extra?.serviceNo + this.raw = extra?.raw + } +} + +function isBusinessOkBody(body: any): boolean { + if (body == null) return false + const code = body.code ?? body.Code + return ( + code === undefined || + code === null || + code === 0 || + code === 200 || + code === '0' || + code === '200' || + body.success === true || + body.Success === true + ) +} + +function throwParcelCreateBusinessError(body: any, fallbackMsg: string): never { + const extracted = extractDraftFailFromBody(body) + const fallbackParsed = parseOmsDraftMarker( + String(body?.msg ?? body?.Msg ?? body?.message ?? body?.Message ?? fallbackMsg ?? '请求失败'), + ) + const cleanMessage = + extracted.cleanMessage || fallbackParsed.cleanMessage || fallbackMsg || '请求失败' + const draftSaved = Boolean(extracted.meta?.draftSaved || fallbackParsed.meta?.draftSaved) + const orderId = extracted.meta?.orderId ?? fallbackParsed.meta?.orderId + const serviceNo = extracted.meta?.serviceNo ?? fallbackParsed.meta?.serviceNo + throw new ParcelCreateBusinessError(cleanMessage, { + draftSaved, + orderId, + serviceNo, + raw: body, + }) +} + /** POST /order/parcel/create */ export async function postOrderParcelCreate(body: Record): Promise { - const res = await axiosInstance.post(orderUrl('/order/parcel/create'), body) - assertBusinessOk(res.data) - return res.data?.data ?? res.data?.Data ?? {} + try { + const res = await axiosInstance.post(orderUrl('/order/parcel/create'), body) + const resBody = res.data + if (!isBusinessOkBody(resBody)) { + throwParcelCreateBusinessError(resBody, '请求失败') + } + // Fake 可能返回 { message, details } 且无 code:若含草稿失败标记仍按失败处理 + const extracted = extractDraftFailFromBody(resBody) + if (extracted.meta?.draftSaved) { + throwParcelCreateBusinessError(resBody, extracted.cleanMessage || '预报未完成') + } + return resBody?.data ?? resBody?.Data ?? {} + } catch (e: any) { + if (e instanceof ParcelCreateBusinessError) throw e + const data = e?.response?.data + if (data) throwParcelCreateBusinessError(data, e?.message || '请求失败') + throw e + } } export interface OrderParcelBatchCreateResult { @@ -1816,3 +1905,78 @@ export async function postOrderStockBindClear(orderId: number): Promise +} + +function normalizeOrderFileItem(raw: any): OrderFileItem { + return { + id: Number(raw?.id ?? raw?.Id ?? 0), + orderId: Number(raw?.orderId ?? raw?.OrderId ?? 0), + fileType: String(raw?.fileType ?? raw?.FileType ?? ''), + fileName: raw?.fileName ?? raw?.FileName, + fileUrl: String(raw?.fileUrl ?? raw?.FileUrl ?? ''), + contentType: raw?.contentType ?? raw?.ContentType, + fileSize: raw?.fileSize ?? raw?.FileSize, + sort: raw?.sort ?? raw?.Sort, + source: raw?.source ?? raw?.Source, + remark: raw?.remark ?? raw?.Remark, + createTime: raw?.createTime ?? raw?.CreateTime, + } +} + +/** GET /order/files */ +export async function getOrderFiles(orderId: number): Promise { + const res = await axiosInstance.get(orderUrl('/order/files'), { params: { orderId } }) + assertBusinessOk(res.data) + const data = unwrapData(res.data) + const rawGroups = data?.groups ?? data?.Groups ?? {} + const groups: Record = {} + Object.keys(rawGroups || {}).forEach((key) => { + const list = Array.isArray(rawGroups[key]) ? rawGroups[key] : [] + groups[key] = list.map(normalizeOrderFileItem) + }) + return { + orderId: Number(data?.orderId ?? data?.OrderId ?? orderId), + groups, + } +} + +/** POST /order/files/upload */ +export async function uploadOrderFiles( + orderId: number, + fileType: string, + files: File[] +): Promise { + const form = new FormData() + form.append('orderId', String(orderId)) + form.append('fileType', fileType) + files.forEach((file) => form.append('files', file)) + const res = await axiosInstance.post(orderUrl('/order/files/upload'), form) + assertBusinessOk(res.data) + const data = unwrapData(res.data) + const list = Array.isArray(data) ? data : data?.items ?? data?.Items ?? [] + return list.map(normalizeOrderFileItem) +} + +/** POST /order/files/delete */ +export async function deleteOrderFile(id: number): Promise { + const res = await axiosInstance.post(orderUrl('/order/files/delete'), { id }) + assertBusinessOk(res.data) +} diff --git a/src/components/HandsontablePro/index.vue b/src/components/HandsontablePro/index.vue index 8710b40..416679f 100644 --- a/src/components/HandsontablePro/index.vue +++ b/src/components/HandsontablePro/index.vue @@ -185,11 +185,25 @@ const selectedRowIndicesSet = ref>(new Set()); const internalColumnOrder = ref([]); - /** 避免 `value || '--'` 把 0、false 当成空(订单金额/重量等会为 0) */ + /** 空值 / 无效数字:不展示 NaN,统一为 '--'(保留 0 / false) */ + function isEmptyCellValue(v: any): boolean { + if (v === undefined || v === null || v === '') return true; + if (typeof v === 'number' && Number.isNaN(v)) return true; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + if (s === '' || s === 'nan' || s === 'null' || s === 'undefined') return true; + } + return false; + } + + function formatCellDisplay(v: any): string { + if (isEmptyCellValue(v)) return '--'; + return String(v); + } + + /** 单元格展示:null / undefined / '' / NaN → '--',0 / false 正常显示 */ function cellDisplayValue(row: any, key: string) { - const v = row?.[key]; - if (v === undefined || v === null || v === '') return '--'; - return v; + return formatCellDisplay(row?.[key]); } function normalizeColumnOrder( @@ -245,8 +259,7 @@ // 创建省略号渲染器 const createEllipsisRenderer = () => { return function(instance: any, td: any, row: number, col: number, prop: any, value: any) { - const text = value || '--'; - const textStr = String(text); + const textStr = formatCellDisplay(value); td.style.textAlign = 'left'; td.style.verticalAlign = 'middle'; @@ -257,7 +270,7 @@ td.textContent = textStr; - if (textStr && textStr !== '--') { + if (textStr !== '--') { td.title = textStr; td.style.cursor = 'help'; } else { @@ -880,22 +893,24 @@ // 自定义渲染器 if (props.customCellRenderer) { const customResult = props.customCellRenderer(row, col, value, rowData); - if (customResult !== null) { - td.innerHTML = customResult; + if (customResult !== null && customResult !== undefined) { + td.innerHTML = isEmptyCellValue(customResult) ? '--' : String(customResult); return; } } // 列定义中的渲染器 if (colDef.renderer) { - td.innerHTML = colDef.renderer(rowData, value); + const html = colDef.renderer(rowData, value); + td.innerHTML = isEmptyCellValue(html) ? '--' : String(html); // 允许单元格内角标(如「多」)不被 td overflow 裁切 td.style.overflow = 'visible'; return; } } - td.innerHTML = value || '--'; + // 默认渲染:null / NaN → '--'(保留 0 / false) + td.innerHTML = formatCellDisplay(value); }; return cellProperties; diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 4844d37..26e2341 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -1283,7 +1283,28 @@ export default { 'order.myOrders.drawer.tabs.weight': 'Weight/Dimensions', 'order.myOrders.drawer.tabs.fee': 'Receivables/Payables', 'order.myOrders.drawer.tabs.track': 'Tracking', + 'order.myOrders.drawer.tabs.attachments': 'Attachments', 'order.myOrders.drawer.tabs.stockBind': 'Bound stock', + 'order.myOrders.drawer.attachments.upload': 'Upload', + 'order.myOrders.drawer.attachments.preview': 'Preview', + 'order.myOrders.drawer.attachments.delete': 'Delete', + 'order.myOrders.drawer.attachments.empty': 'No attachments', + 'order.myOrders.drawer.attachments.loadFailed': 'Failed to load attachments', + 'order.myOrders.drawer.attachments.uploadSuccess': 'Uploaded', + 'order.myOrders.drawer.attachments.uploadFailed': 'Upload failed', + 'order.myOrders.drawer.attachments.deleteSuccess': 'Deleted', + 'order.myOrders.drawer.attachments.deleteFailed': 'Delete failed', + 'order.myOrders.drawer.attachments.deleteConfirmTitle': 'Delete attachment', + 'order.myOrders.drawer.attachments.deleteConfirm': 'Delete this attachment?', + 'order.myOrders.drawer.attachments.types.tradeProof': 'Trade proof', + 'order.myOrders.drawer.attachments.types.systemLabel': 'System label', + 'order.myOrders.drawer.attachments.types.downstreamLabel': 'Downstream label', + 'order.myOrders.drawer.attachments.types.lastMileLabel': 'Last-mile label', + 'order.myOrders.drawer.attachments.types.infraredImage': 'Infrared image', + 'order.myOrders.drawer.attachments.source.upload': 'Upload', + 'order.myOrders.drawer.attachments.source.systemGenerate': 'System generated', + 'order.myOrders.drawer.attachments.source.downstream': 'Downstream', + 'order.myOrders.drawer.attachments.source.migrate': 'Migrated', 'order.myOrders.drawer.orderBasic': 'Basic Info', 'order.myOrders.drawer.basic.viewAll': 'All', 'order.myOrders.drawer.basic.group.identity': 'Order IDs', @@ -1314,6 +1335,10 @@ export default { 'order.myOrders.table.cargoAttribute': 'Cargo Attribute', 'order.myOrders.drawer.orderStatus': 'Order Status', 'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason', + 'order.myOrders.drawer.failGuideTitle': 'Pre-declaration incomplete — what to change', + 'order.myOrders.drawer.failGuideRaw': 'Details', + 'order.myOrders.drawer.failGuideAskSupport': 'Contact support', + 'order.myOrders.drawer.failGuideEdit': 'Edit order', 'order.myOrders.drawer.allocationPendingReason': 'Allocation Pending Reason', 'order.myOrders.drawer.warehouse': 'Warehouse', 'order.myOrders.drawer.receiveTime': 'Receive Time', @@ -1704,6 +1729,43 @@ export default { 'order.placeOrder.single.successGoOrders': 'Go to My Orders', 'order.placeOrder.single.successStay': 'Stay on this page', 'order.placeOrder.single.failed': 'Failed to place order', + 'order.placeOrder.single.failTitle': 'Order was not placed', + 'order.placeOrder.single.failTitleDraft': 'Pre-declaration failed (draft saved)', + 'order.placeOrder.single.failWhatHappened': 'What happened', + 'order.placeOrder.single.failHowToFix': 'What to change', + 'order.placeOrder.single.failRawReason': 'Details', + 'order.placeOrder.single.failAskSupport': 'Contact support', + 'order.placeOrder.single.failCopied': 'Waybill No. copied', + 'order.placeOrder.single.failCopyFailed': 'Copy failed. Please select the number manually.', + 'order.placeOrder.single.failDraftSavedNote': + 'The order was saved as a draft, but pre-declaration did not finish. Edit the draft in My Orders and pre-declare again — do not submit again here (that would create a duplicate).', + 'order.placeOrder.single.failNotSavedNote': + 'The order was not saved. Continue editing and retry, or save it as a draft for later.', + 'order.placeOrder.single.failServiceNo': 'Waybill No.', + 'order.placeOrder.single.failContinueEdit': 'Continue editing', + 'order.placeOrder.single.failEditDraft': 'Edit draft', + 'order.placeOrder.single.failKeepDraftEdit': 'Keep draft and edit', + 'order.placeOrder.single.failSaveAsDraft': 'Save as draft', + 'order.placeOrder.single.failSaveAsDraftOk': 'Saved as draft', + 'order.placeOrder.single.failSaveAsDraftFailed': 'Failed to save draft', + 'order.placeOrder.single.failSaveAsDraftGo': 'View draft', + 'order.placeOrder.single.failSaveAsDraftStay': 'Stay on this page', + 'order.placeOrder.single.failSaveLocal': 'Save local draft', + 'order.placeOrder.single.failSaveLocalOk': 'Saved locally. You can continue later.', + 'order.placeOrder.single.failSaveLocalFailed': 'Failed to save local draft', + 'order.placeOrder.single.failGoDrafts': 'View draft list', + 'order.placeOrder.single.failAbandon': 'Discard', + 'order.placeOrder.single.failCancelSubmit': 'Cancel submission (delete draft)', + 'order.placeOrder.single.failAbandonTitle': 'Discard this form?', + 'order.placeOrder.single.failAbandonContent': 'The current form will be cleared. This cannot be undone.', + 'order.placeOrder.single.failAbandonOk': 'Discard', + 'order.placeOrder.single.failAbandonDone': 'Form cleared', + 'order.placeOrder.single.failCancelDraftTitle': 'Cancel this submission?', + 'order.placeOrder.single.failCancelDraftContent': 'The draft order just saved will be deleted. This cannot be undone.', + 'order.placeOrder.single.failCancelDraftOk': 'Delete draft', + 'order.placeOrder.single.failCancelKeep': 'Keep editing', + 'order.placeOrder.single.failCancelDraftDone': 'Draft deleted', + 'order.placeOrder.single.failCancelDraftFailed': 'Failed to delete draft', 'order.placeOrder.standardTemplates': 'Standard templates', 'order.placeOrder.customTemplates': 'Custom templates', @@ -3640,7 +3702,7 @@ export default { 'channel.salesProducts.form.productName': 'Product name', 'channel.salesProducts.form.group': 'Group', 'channel.salesProducts.form.efficiency': 'Transit time', - 'channel.salesProducts.form.customerWaybill': 'Customer waybill', + 'channel.salesProducts.form.customerWaybill': 'System label', 'channel.salesProducts.form.placeholder.labelTemplate': 'Select label template (global default if empty)', 'channel.salesProducts.form.blacklistArea': 'Blacklist area', 'channel.salesProducts.form.remoteAreaDivision': 'Remote area division', @@ -3685,7 +3747,14 @@ export default { 'channel.salesProducts.drawer.footer.cancel': 'Cancel', 'channel.salesProducts.drawer.footer.generatePriceList': 'Generate price table', + 'channel.salesProducts.drawer.footer.syncPriceList': 'Sync price table', 'channel.salesProducts.drawer.footer.save': 'Save', + 'channel.salesProducts.drawer.priceList.hint': 'Existing rows keep prices; select rows to batch-fill', + 'channel.salesProducts.drawer.priceList.batchFill': 'Batch fill', + 'channel.salesProducts.drawer.priceList.batchFillEmpty': 'Fill empty/0 only', + 'channel.salesProducts.drawer.priceList.copyPrev': 'Copy previous row', + 'channel.salesProducts.drawer.priceList.batchFillSelected': '{count} rows selected', + 'channel.salesProducts.drawer.priceList.batchFillValue': 'Price', // Channel - Service Channels 'channel.serviceChannels.search.placeholder': 'Enter channel name', @@ -3752,13 +3821,21 @@ export default { 'channel.serviceChannels.drawer.form.currency': 'Currency', 'channel.serviceChannels.drawer.form.chargingCondition': 'Charging condition', 'channel.serviceChannels.drawer.section.billingFactors': 'Billing factors', + 'channel.serviceChannels.drawer.section.priceListTitle': 'Freight', 'channel.serviceChannels.drawer.table.billingFactors.serialNo': 'No.', 'channel.serviceChannels.drawer.table.billingFactors.factor': 'Factor', 'channel.serviceChannels.drawer.table.billingFactors.factorValues': 'Factor values', 'channel.serviceChannels.drawer.table.billingFactors.actions': 'Action', 'channel.serviceChannels.drawer.footer.cancel': 'Cancel', 'channel.serviceChannels.drawer.footer.generatePriceList': 'Generate price table', + 'channel.serviceChannels.drawer.footer.syncPriceList': 'Sync price table', 'channel.serviceChannels.drawer.footer.save': 'Save', + 'channel.serviceChannels.drawer.priceList.hint': 'Existing combos keep unit prices; select rows to batch-fill', + 'channel.serviceChannels.drawer.priceList.batchFill': 'Batch fill', + 'channel.serviceChannels.drawer.priceList.batchFillEmpty': 'Fill empty/0 only', + 'channel.serviceChannels.drawer.priceList.copyPrev': 'Copy previous row', + 'channel.serviceChannels.drawer.priceList.batchFillSelected': '{count} rows selected', + 'channel.serviceChannels.drawer.priceList.batchFillValue': 'Unit price', 'channel.serviceChannels.createTime': 'Created: {time}', 'channel.serviceChannels.empty.selectChannel': 'Select a channel from the left', 'channel.serviceChannels.context.channel': 'Channel', @@ -4020,17 +4097,54 @@ export default { 'channel.autoTrigger.form.notifierPlaceholder': 'Optional notifier users for warn/problem', 'channel.autoTrigger.form.loadExample': 'Load oversize example', 'channel.autoTrigger.form.legacyTitle': 'Legacy plain-text condition detected — rebuild with the builder below', - 'channel.autoTrigger.form.testFormula': 'Test formula', + 'channel.autoTrigger.form.testFormula': 'Verify condition', 'channel.autoTrigger.form.testSample': 'Sample data', - 'channel.autoTrigger.form.testMatched': 'Matched', - 'channel.autoTrigger.form.testNotMatched': 'Not matched', + 'channel.autoTrigger.form.testByOrder': 'Real order', + 'channel.autoTrigger.form.testHint': + 'Evaluate the formula against sample data or an existing order. Does not affect live orders.', + 'channel.autoTrigger.form.testRun': 'Run test', + 'channel.autoTrigger.form.testNeedFormula': 'Enter a trigger formula first', + 'channel.autoTrigger.form.testNeedOrderId': 'Enter a valid order ID', + 'channel.autoTrigger.form.testOrderId': 'Order ID', + 'channel.autoTrigger.form.testOrderIdPlaceholder': 't_order.Id', + 'channel.autoTrigger.form.testMatched': 'Matched (true)', + 'channel.autoTrigger.form.testNotMatched': 'Not matched (false)', 'channel.autoTrigger.form.testFailed': 'Formula test failed', - 'channel.autoTrigger.form.sampleCountry': 'Country', + 'channel.autoTrigger.form.sampleCountry': 'Country code', + 'channel.autoTrigger.form.sampleCountryName': 'Country name', 'channel.autoTrigger.form.sampleCustomer': 'Customer name', 'channel.autoTrigger.form.sampleProduct': 'Product code', - 'channel.autoTrigger.form.sampleLength': 'Length', - 'channel.autoTrigger.form.sampleWidth': 'Width', - 'channel.autoTrigger.form.sampleHeight': 'Height', + 'channel.autoTrigger.form.sampleLength': 'Length (cm)', + 'channel.autoTrigger.form.sampleWidth': 'Width (cm)', + 'channel.autoTrigger.form.sampleHeight': 'Height (cm)', + 'channel.autoTrigger.form.sampleWeight': 'Weight (kg)', + 'channel.autoTrigger.ai.open': 'AI formula', + 'channel.autoTrigger.ai.toolbarHint': 'Describe the rule and let AI fill the formula', + 'channel.autoTrigger.ai.title': 'AI trigger condition', + 'channel.autoTrigger.ai.configLink': 'Configure AI', + 'channel.autoTrigger.ai.intro': 'Pick an enabled AI provider and model, optionally send the formula skill, then generate and fill.', + 'channel.autoTrigger.ai.provider': 'AI provider', + 'channel.autoTrigger.ai.providerPlaceholder': 'Select AI', + 'channel.autoTrigger.ai.model': 'Model', + 'channel.autoTrigger.ai.modelPlaceholder': 'Select or type a model', + 'channel.autoTrigger.ai.sendSkill': 'Send formula skill', + 'channel.autoTrigger.ai.sendSkillHint': 'Includes syntax rules and field catalog (recommended)', + 'channel.autoTrigger.ai.prompt': 'Rule description', + 'channel.autoTrigger.ai.promptPlaceholder': 'e.g. UK oversize intercept… (Ctrl+Enter to generate)', + 'channel.autoTrigger.ai.generate': 'Generate', + 'channel.autoTrigger.ai.generating': 'Generating…', + 'channel.autoTrigger.ai.clear': 'Clear result', + 'channel.autoTrigger.ai.result': 'Parsed result', + 'channel.autoTrigger.ai.sampleHint': 'Sample', + 'channel.autoTrigger.ai.rawReply': 'Full reply', + 'channel.autoTrigger.ai.fillFormula': 'Fill formula', + 'channel.autoTrigger.ai.filled': 'Formula filled', + 'channel.autoTrigger.ai.generateOk': 'Formula ready — review then fill', + 'channel.autoTrigger.ai.generateFail': 'Generation failed', + 'channel.autoTrigger.ai.emptyReply': 'Empty AI reply', + 'channel.autoTrigger.ai.parseFail': 'Could not parse a formula from the reply', + 'channel.autoTrigger.ai.needProviderModelPrompt': 'Select AI, model, and enter a description', + 'channel.autoTrigger.ai.loadProvidersFail': 'Failed to load AI providers', // Channel - Issue Template 'channel.issueTemplate.actions.add': 'Add', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 0041eca..e18d3b7 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -1171,7 +1171,28 @@ export default { 'order.myOrders.drawer.tabs.weight': '件重尺', 'order.myOrders.drawer.tabs.fee': '应收应付', 'order.myOrders.drawer.tabs.track': '轨迹', + 'order.myOrders.drawer.tabs.attachments': '附件', 'order.myOrders.drawer.tabs.stockBind': '绑定包裹/库存', + 'order.myOrders.drawer.attachments.upload': '上传', + 'order.myOrders.drawer.attachments.preview': '预览', + 'order.myOrders.drawer.attachments.delete': '删除', + 'order.myOrders.drawer.attachments.empty': '暂无附件', + 'order.myOrders.drawer.attachments.loadFailed': '加载附件失败', + 'order.myOrders.drawer.attachments.uploadSuccess': '上传成功', + 'order.myOrders.drawer.attachments.uploadFailed': '上传失败', + 'order.myOrders.drawer.attachments.deleteSuccess': '已删除', + 'order.myOrders.drawer.attachments.deleteFailed': '删除失败', + 'order.myOrders.drawer.attachments.deleteConfirmTitle': '删除附件', + 'order.myOrders.drawer.attachments.deleteConfirm': '确认删除该附件?', + 'order.myOrders.drawer.attachments.types.tradeProof': '交易证明', + 'order.myOrders.drawer.attachments.types.systemLabel': '系统面单', + 'order.myOrders.drawer.attachments.types.downstreamLabel': '下游面单', + 'order.myOrders.drawer.attachments.types.lastMileLabel': '末端面单', + 'order.myOrders.drawer.attachments.types.infraredImage': '红外图片', + 'order.myOrders.drawer.attachments.source.upload': '上传', + 'order.myOrders.drawer.attachments.source.systemGenerate': '系统生成', + 'order.myOrders.drawer.attachments.source.downstream': '下游回传', + 'order.myOrders.drawer.attachments.source.migrate': '迁移', 'order.myOrders.drawer.orderBasic': '基本信息', 'order.myOrders.drawer.basic.viewAll': '全部', 'order.myOrders.drawer.basic.group.identity': '单号信息', @@ -1202,6 +1223,10 @@ export default { 'order.myOrders.table.cargoAttribute': '货物属性', 'order.myOrders.drawer.orderStatus': '订单状态', 'order.myOrders.drawer.predeclareFailReason': '预报失败原因', + 'order.myOrders.drawer.failGuideTitle': '预报未完成 — 建议修改', + 'order.myOrders.drawer.failGuideRaw': '详细原因', + 'order.myOrders.drawer.failGuideAskSupport': '联系客服', + 'order.myOrders.drawer.failGuideEdit': '去修改', 'order.myOrders.drawer.allocationPendingReason': '待排货原因', 'order.myOrders.drawer.warehouse': '仓库', 'order.myOrders.drawer.receiveTime': '收货时间', @@ -1591,6 +1616,41 @@ export default { 'order.placeOrder.single.successGoOrders': '查看我的订单', 'order.placeOrder.single.successStay': '继续留在本页', 'order.placeOrder.single.failed': '单票下单失败', + 'order.placeOrder.single.failTitle': '下单未成功', + 'order.placeOrder.single.failTitleDraft': '预报未完成(草稿已保存)', + 'order.placeOrder.single.failWhatHappened': '发生了什么', + 'order.placeOrder.single.failHowToFix': '建议修改', + 'order.placeOrder.single.failRawReason': '详细原因', + 'order.placeOrder.single.failAskSupport': '联系客服', + 'order.placeOrder.single.failCopied': '运单号已复制', + 'order.placeOrder.single.failCopyFailed': '复制失败,请手动选中运单号', + 'order.placeOrder.single.failDraftSavedNote': '订单已保存为草稿,但预报未完成。请到「我的订单」修改草稿后重新预报,不要在本页再次提交(会重复建单)。', + 'order.placeOrder.single.failNotSavedNote': '订单尚未落库。您可以继续修改后重试,或先保存为草稿稍后再预报。', + 'order.placeOrder.single.failServiceNo': '运单号', + 'order.placeOrder.single.failContinueEdit': '继续修改', + 'order.placeOrder.single.failEditDraft': '去修改草稿', + 'order.placeOrder.single.failKeepDraftEdit': '保留草稿并修改', + 'order.placeOrder.single.failSaveAsDraft': '保存为草稿', + 'order.placeOrder.single.failSaveAsDraftOk': '已保存为草稿', + 'order.placeOrder.single.failSaveAsDraftFailed': '保存草稿失败', + 'order.placeOrder.single.failSaveAsDraftGo': '查看草稿', + 'order.placeOrder.single.failSaveAsDraftStay': '继续留在本页', + 'order.placeOrder.single.failSaveLocal': '保存到本地草稿', + 'order.placeOrder.single.failSaveLocalOk': '已保存到本地,可稍后继续填写', + 'order.placeOrder.single.failSaveLocalFailed': '本地草稿保存失败', + 'order.placeOrder.single.failGoDrafts': '查看草稿列表', + 'order.placeOrder.single.failAbandon': '放弃本次', + 'order.placeOrder.single.failCancelSubmit': '取消提交(删除草稿)', + 'order.placeOrder.single.failAbandonTitle': '放弃本次填写?', + 'order.placeOrder.single.failAbandonContent': '将清空当前表单内容,此操作不可恢复。', + 'order.placeOrder.single.failAbandonOk': '确认放弃', + 'order.placeOrder.single.failAbandonDone': '已清空本次填写', + 'order.placeOrder.single.failCancelDraftTitle': '取消本次提交?', + 'order.placeOrder.single.failCancelDraftContent': '将删除刚保存的草稿订单,此操作不可恢复。', + 'order.placeOrder.single.failCancelDraftOk': '确认删除草稿', + 'order.placeOrder.single.failCancelKeep': '再想想', + 'order.placeOrder.single.failCancelDraftDone': '草稿已删除', + 'order.placeOrder.single.failCancelDraftFailed': '删除草稿失败', 'order.placeOrder.standardTemplates': '标准模板', 'order.placeOrder.customTemplates': '自定义模板', @@ -3660,7 +3720,7 @@ export default { 'channel.salesProducts.form.productName': '产品名称', 'channel.salesProducts.form.group': '分组', 'channel.salesProducts.form.efficiency': '时效', - 'channel.salesProducts.form.customerWaybill': '客户面单', + 'channel.salesProducts.form.customerWaybill': '系统面单', 'channel.salesProducts.form.blacklistArea': '黑名单区', 'channel.salesProducts.form.remoteAreaDivision': '偏远分区', 'channel.salesProducts.form.priceArea': '价格分区', @@ -3705,7 +3765,14 @@ export default { 'channel.salesProducts.drawer.footer.cancel': '取消', 'channel.salesProducts.drawer.footer.generatePriceList': '生成价格表', + 'channel.salesProducts.drawer.footer.syncPriceList': '同步价格表', 'channel.salesProducts.drawer.footer.save': '保存', + 'channel.salesProducts.drawer.priceList.hint': '已有组合会保留价格;勾选行可批量填价', + 'channel.salesProducts.drawer.priceList.batchFill': '批量填价', + 'channel.salesProducts.drawer.priceList.batchFillEmpty': '只填空白/0', + 'channel.salesProducts.drawer.priceList.copyPrev': '复制上一行', + 'channel.salesProducts.drawer.priceList.batchFillSelected': '已选 {count} 行', + 'channel.salesProducts.drawer.priceList.batchFillValue': '统一价格', // 渠道 - 服务渠道 'channel.serviceChannels.search.placeholder': '请输入渠道名称', @@ -3774,13 +3841,21 @@ export default { 'channel.serviceChannels.drawer.form.currency': '币种', 'channel.serviceChannels.drawer.form.chargingCondition': '收费条件', 'channel.serviceChannels.drawer.section.billingFactors': '计费因素', + 'channel.serviceChannels.drawer.section.priceListTitle': '运费', 'channel.serviceChannels.drawer.table.billingFactors.serialNo': '序号', 'channel.serviceChannels.drawer.table.billingFactors.factor': '因素', 'channel.serviceChannels.drawer.table.billingFactors.factorValues': '因素值', 'channel.serviceChannels.drawer.table.billingFactors.actions': '操作', 'channel.serviceChannels.drawer.footer.cancel': '取消', 'channel.serviceChannels.drawer.footer.generatePriceList': '生成价格表', + 'channel.serviceChannels.drawer.footer.syncPriceList': '同步价格表', 'channel.serviceChannels.drawer.footer.save': '保存', + 'channel.serviceChannels.drawer.priceList.hint': '已有组合会保留单价;勾选行可批量填价', + 'channel.serviceChannels.drawer.priceList.batchFill': '批量填价', + 'channel.serviceChannels.drawer.priceList.batchFillEmpty': '只填空白/0', + 'channel.serviceChannels.drawer.priceList.copyPrev': '复制上一行', + 'channel.serviceChannels.drawer.priceList.batchFillSelected': '已选 {count} 行', + 'channel.serviceChannels.drawer.priceList.batchFillValue': '统一单价', 'channel.serviceChannels.createTime': '创建: {time}', 'channel.serviceChannels.empty.selectChannel': '请从左侧选择渠道', 'channel.serviceChannels.context.channel': '当前渠道', @@ -4057,17 +4132,53 @@ export default { 'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,请选择通知人', 'channel.autoTrigger.form.loadExample': '载入超尺寸示例', 'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置', - 'channel.autoTrigger.form.testFormula': '测试公式', + 'channel.autoTrigger.form.testFormula': '验证触发条件', 'channel.autoTrigger.form.testSample': '样例数据', - 'channel.autoTrigger.form.testMatched': '命中规则', - 'channel.autoTrigger.form.testNotMatched': '未命中', + 'channel.autoTrigger.form.testByOrder': '真实订单', + 'channel.autoTrigger.form.testHint': '用样例或已有订单试算当前公式,确认是否会命中(拦截/警告/问题件)。不影响正式下单。', + 'channel.autoTrigger.form.testRun': '立即验证', + 'channel.autoTrigger.form.testNeedFormula': '请先填写触发条件公式', + 'channel.autoTrigger.form.testNeedOrderId': '请填写有效的订单 ID', + 'channel.autoTrigger.form.testOrderId': '订单 ID', + 'channel.autoTrigger.form.testOrderIdPlaceholder': '输入 t_order.Id', + 'channel.autoTrigger.form.testMatched': '命中规则(条件为真)', + 'channel.autoTrigger.form.testNotMatched': '未命中(条件为假)', 'channel.autoTrigger.form.testFailed': '公式测试失败', - 'channel.autoTrigger.form.sampleCountry': '国家', + 'channel.autoTrigger.form.sampleCountry': '国家代码', + 'channel.autoTrigger.form.sampleCountryName': '国家名称', 'channel.autoTrigger.form.sampleCustomer': '客户名称', 'channel.autoTrigger.form.sampleProduct': '销售产品', - 'channel.autoTrigger.form.sampleLength': '长度', - 'channel.autoTrigger.form.sampleWidth': '宽度', - 'channel.autoTrigger.form.sampleHeight': '高度', + 'channel.autoTrigger.form.sampleLength': '长度 (cm)', + 'channel.autoTrigger.form.sampleWidth': '宽度 (cm)', + 'channel.autoTrigger.form.sampleHeight': '高度 (cm)', + 'channel.autoTrigger.form.sampleWeight': '实重 (kg)', + 'channel.autoTrigger.ai.open': 'AI 写公式', + 'channel.autoTrigger.ai.toolbarHint': '不会写公式?用 AI 助手按描述生成后填入', + 'channel.autoTrigger.ai.title': 'AI 写触发条件', + 'channel.autoTrigger.ai.configLink': '配置 AI', + 'channel.autoTrigger.ai.intro': '选择已启用的 AI 与模型,勾选发送 skill 后描述规则,生成中文公式并可一键填入。', + 'channel.autoTrigger.ai.provider': 'AI 提供商', + 'channel.autoTrigger.ai.providerPlaceholder': '选择 AI', + 'channel.autoTrigger.ai.model': '模型', + 'channel.autoTrigger.ai.modelPlaceholder': '选择或输入模型', + 'channel.autoTrigger.ai.sendSkill': '发送公式 skill', + 'channel.autoTrigger.ai.sendSkillHint': '附带语法规则与字段目录,推荐开启', + 'channel.autoTrigger.ai.prompt': '规则描述', + 'channel.autoTrigger.ai.promptPlaceholder': '例如:英国超尺寸拦截,排除某产品…(Ctrl+Enter 生成)', + 'channel.autoTrigger.ai.generate': '生成公式', + 'channel.autoTrigger.ai.generating': '生成中…', + 'channel.autoTrigger.ai.clear': '清空结果', + 'channel.autoTrigger.ai.result': '解析结果', + 'channel.autoTrigger.ai.sampleHint': '验证样例', + 'channel.autoTrigger.ai.rawReply': '查看完整回复', + 'channel.autoTrigger.ai.fillFormula': '填入公式', + 'channel.autoTrigger.ai.filled': '已填入公式', + 'channel.autoTrigger.ai.generateOk': '公式已生成,可预览后填入', + 'channel.autoTrigger.ai.generateFail': '生成失败', + 'channel.autoTrigger.ai.emptyReply': 'AI 未返回内容', + 'channel.autoTrigger.ai.parseFail': '未能解析出公式,请查看完整回复或换一种描述重试', + 'channel.autoTrigger.ai.needProviderModelPrompt': '请选择 AI、模型并填写描述', + 'channel.autoTrigger.ai.loadProvidersFail': '加载 AI 提供商失败', // 渠道 - 问题模板 'channel.issueTemplate.actions.add': '新增', diff --git a/src/utils/parcelCreateFailGuide.ts b/src/utils/parcelCreateFailGuide.ts new file mode 100644 index 0000000..27cf540 --- /dev/null +++ b/src/utils/parcelCreateFailGuide.ts @@ -0,0 +1,233 @@ +/** 下单创建失败:解析草稿标记 + 原因→修改指引 */ + +export type PlaceOrderFailSection = 'basic' | 'sender' | 'recipient' | 'declaration' + +export interface ParcelCreateDraftMeta { + draftSaved: boolean + orderId?: number + serviceNo?: string +} + +export interface ParcelCreateFailGuide { + /** 原始技术原因(可用于排查) */ + rawReason: string + /** 对客户展示的人话原因 */ + reason: string + tips: string[] + section: PlaceOrderFailSection + draftSaved: boolean + orderId?: number + serviceNo?: string + /** 是否建议联系客服 */ + suggestSupport?: boolean +} + +const OMS_DRAFT_RE = /\[\[omsDraft:(\{[\s\S]*\})\]\]\s*$/ +export const OMS_DRAFT_SAVED_ERROR_CODE = 'OMS_DRAFT_SAVED' + +export function parseOmsDraftMarker(message: string): { + cleanMessage: string + meta?: ParcelCreateDraftMeta +} { + const raw = String(message || '') + const match = raw.match(OMS_DRAFT_RE) + if (!match || match.index == null) { + return { cleanMessage: raw.trim() } + } + try { + const parsed = JSON.parse(match[1]) as Record + const orderIdRaw = Number(parsed.orderId ?? parsed.OrderId) + const serviceNo = String(parsed.serviceNo ?? parsed.ServiceNo ?? '').trim() + return { + cleanMessage: raw.slice(0, match.index).trim(), + meta: { + draftSaved: Boolean(parsed.draftSaved ?? parsed.DraftSaved), + orderId: Number.isFinite(orderIdRaw) && orderIdRaw > 0 ? orderIdRaw : undefined, + serviceNo: serviceNo || undefined, + }, + } + } catch { + return { cleanMessage: raw.replace(OMS_DRAFT_RE, '').trim() } + } +} + +/** 从业务错误响应体提取草稿元数据(优先 data/details,兼容 msg 标记) */ +export function extractDraftFailFromBody(body: any): { + cleanMessage: string + meta?: ParcelCreateDraftMeta & { errorCode?: string } +} { + const rawMsg = String( + body?.msg ?? body?.Msg ?? body?.message ?? body?.Message ?? '', + ) + const fromMarker = parseOmsDraftMarker(rawMsg) + let draftSaved = Boolean(fromMarker.meta?.draftSaved) + let orderId = fromMarker.meta?.orderId + let serviceNo = fromMarker.meta?.serviceNo + let errorCode = + String(body?.errorCode ?? body?.ErrorCode ?? '').trim() || undefined + + const candidates = [ + body?.data, + body?.Data, + body?.details, + body?.Details, + body?.error?.details, + body?.error?.Details, + body?.error?.data, + ] + + for (const candidate of candidates) { + if (candidate == null) continue + let obj = candidate + if (typeof candidate === 'string') { + const trimmed = candidate.trim() + if (!trimmed) continue + try { + obj = JSON.parse(trimmed) + } catch { + continue + } + } + if (!obj || typeof obj !== 'object') continue + if (obj.draftSaved === true || obj.DraftSaved === true) draftSaved = true + const id = Number(obj.orderId ?? obj.OrderId) + if (Number.isFinite(id) && id > 0) orderId = id + const sn = String(obj.serviceNo ?? obj.ServiceNo ?? '').trim() + if (sn) serviceNo = sn + const code = String(obj.errorCode ?? obj.ErrorCode ?? '').trim() + if (code) errorCode = code + const reason = String(obj.reason ?? obj.Reason ?? '').trim() + if (reason && !fromMarker.cleanMessage) { + fromMarker.cleanMessage = reason + } + } + + if (errorCode === OMS_DRAFT_SAVED_ERROR_CODE) draftSaved = true + + return { + cleanMessage: fromMarker.cleanMessage || rawMsg.trim(), + meta: + draftSaved || orderId || serviceNo || errorCode + ? { draftSaved, orderId, serviceNo, errorCode } + : undefined, + } +} + +type GuideRule = { + test: RegExp + plainReason: string + tips: string[] + section: PlaceOrderFailSection + suggestSupport?: boolean +} + +const GUIDE_RULES: GuideRule[] = [ + { + test: /黑名单/, + plainReason: '收件地址在当前运输方式的禁运/黑名单范围内', + tips: ['请更换收件国家、省市或邮编', '或尝试更换运输方式(销售产品)'], + section: 'recipient', + }, + { + test: /价格分区未匹配/, + plainReason: '收件邮编/国家未匹配到可用价格分区', + tips: ['请核对收件国家与邮编是否正确', '若仍无法匹配,请更换运输方式或联系客服'], + section: 'recipient', + suggestSupport: true, + }, + { + test: /计费重|件重尺|货物重量|长、宽、高|实重/, + plainReason: '包裹尺寸或重量无法完成计费', + tips: ['请检查长、宽、高与实重是否填写完整且大于 0'], + section: 'basic', + }, + { + test: /产品卖价未匹配|卖价未匹配|未配置计重|未命中价格|未命中规则|未命中.*快照/, + plainReason: '当前运输方式暂无可用报价或规则未配置完整', + tips: ['请更换运输方式,或联系客服确认该线路价格/规则是否已配置'], + section: 'basic', + suggestSupport: true, + }, + { + test: /销售产品不存在|产品代码|产品未启用|仓库不存在|仓库ID|货物属性|出口报关/, + plainReason: '基础信息选择有误或选项已失效', + tips: ['请检查运输方式、仓库、货物属性等基础信息是否选择正确'], + section: 'basic', + }, + { + test: /客户单号已存在/, + plainReason: '客户单号与已有订单重复', + tips: ['请修改客户单号后重新提交'], + section: 'basic', + }, + { + test: /客户单号不能为空|包裹数量/, + plainReason: '基础必填信息不完整', + tips: ['请完善基础信息中的必填项'], + section: 'basic', + }, + { + test: /收件人信息不完整/, + plainReason: '收件人必填信息不完整', + tips: ['请补全收件人姓名、国家、地址1、邮编等必填项'], + section: 'recipient', + }, + { + test: /发件人姓名|sender\s*name/i, + plainReason: '尚未选择发件人地址', + tips: ['请选择或维护发件地址'], + section: 'sender', + }, + { + test: /申报明细|申报币种/, + plainReason: '申报信息不完整', + tips: ['请检查申报明细必填字段与申报币种是否完整'], + section: 'declaration', + }, + { + test: /完善手机号和邮箱|联系方式/, + plainReason: '账户联系方式未完善', + tips: ['请先完善账户手机号与邮箱后再下单'], + section: 'basic', + }, +] + +export function buildParcelCreateFailGuide(error: unknown): ParcelCreateFailGuide { + const err = error as { + message?: string + draftSaved?: boolean + orderId?: number + serviceNo?: string + raw?: any + response?: { data?: any } + } + + const body = err?.raw ?? err?.response?.data + const fromBody = body ? extractDraftFailFromBody(body) : null + const rawMessage = String(err?.message || fromBody?.cleanMessage || error || '') + const { cleanMessage, meta } = fromBody + ? { cleanMessage: fromBody.cleanMessage || parseOmsDraftMarker(rawMessage).cleanMessage, meta: fromBody.meta } + : parseOmsDraftMarker(rawMessage) + + const rawReason = cleanMessage || '下单未成功,请检查表单后重试' + const hit = GUIDE_RULES.find((rule) => rule.test.test(rawReason)) + const draftSaved = Boolean(meta?.draftSaved || err?.draftSaved) + const orderId = meta?.orderId ?? (Number(err?.orderId) > 0 ? Number(err.orderId) : undefined) + const serviceNo = meta?.serviceNo || (err?.serviceNo ? String(err.serviceNo) : undefined) + + return { + rawReason, + reason: hit?.plainReason || rawReason, + tips: hit?.tips?.length ? hit.tips : ['请根据失败原因核对表单后重试;若仍失败请联系客服'], + section: hit?.section ?? 'basic', + draftSaved, + orderId, + serviceNo, + suggestSupport: hit?.suggestSupport ?? !hit, + } +} + +/** 仅根据失败原因文案构建指引(用于我的订单草稿详情) */ +export function buildFailGuideFromReason(reason: string): ParcelCreateFailGuide { + return buildParcelCreateFailGuide({ message: String(reason || '') }) +} diff --git a/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue b/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue new file mode 100644 index 0000000..6c9894c --- /dev/null +++ b/src/views/channel/autoTrigger/components/FormulaAiAssistDrawer.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/src/views/channel/autoTrigger/components/FormulaConditionEditor.vue b/src/views/channel/autoTrigger/components/FormulaConditionEditor.vue index 36f881e..76fa907 100644 --- a/src/views/channel/autoTrigger/components/FormulaConditionEditor.vue +++ b/src/views/channel/autoTrigger/components/FormulaConditionEditor.vue @@ -1,22 +1,36 @@ diff --git a/src/views/order/myOrders/components/OrderDetailDrawer.vue b/src/views/order/myOrders/components/OrderDetailDrawer.vue index b1fc9ed..653a369 100644 --- a/src/views/order/myOrders/components/OrderDetailDrawer.vue +++ b/src/views/order/myOrders/components/OrderDetailDrawer.vue @@ -79,6 +79,30 @@ +
+
+ {{ $t('order.myOrders.drawer.failGuideTitle') }} +
+

{{ predeclareFailGuide.reason }}

+

+ {{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }} +

+
    +
  • {{ tip }}
  • +
+
+ + {{ $t('order.myOrders.drawer.failGuideAskSupport') }} + + + {{ $t('order.myOrders.drawer.failGuideEdit') }} + +
+
+
+ +
+ +
+
{ return code ? getOrderStatusMeta(code) : null; }); +const predeclareFailGuide = computed(() => { + const status = resolveOrderStatusCode(detail.value); + if (status !== 'draft') return null; + const reason = String(getOrderValue('predeclareFailReason') || '').trim(); + if (!reason) return null; + return buildFailGuideFromReason(reason); +}); + type FieldInputType = 'text' | 'number' | 'select' | 'switch'; interface FormFieldDef { @@ -783,6 +827,7 @@ const activeTab = ref('basic'); const tabList = computed(() => [ { key: 'basic', label: t('order.myOrders.drawer.tabs.basic') }, { key: 'track', label: t('order.myOrders.drawer.tabs.track') }, + { key: 'attachments', label: t('order.myOrders.drawer.tabs.attachments') }, { key: 'stockBind', label: t('order.myOrders.drawer.tabs.stockBind') }, ]); @@ -1696,7 +1741,7 @@ watch( ); defineExpose({ - openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'stockBind' }) => { + openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => { const tab = resolveDrawerTab(opts?.tab); activeTab.value = tab; basicViewMode.value = 'all'; @@ -1871,7 +1916,51 @@ defineExpose({ } .basic-summary-actions { + margin-top: 10px; +} + +.predeclare-fail-guide { margin-top: 12px; + padding: 12px 14px; + border-radius: 8px; + background: #fff7e8; + border: 1px solid #ffe4ba; + + &__title { + font-size: 13px; + font-weight: 600; + color: #1d2129; + margin-bottom: 6px; + } + + &__reason { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: #4e5969; + } + + &__raw { + margin: 4px 0 0; + font-size: 12px; + color: #86909c; + word-break: break-word; + } + + &__tips { + margin: 8px 0 0; + padding-left: 18px; + font-size: 12px; + line-height: 1.6; + color: #4e5969; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; + } } .basic-chip { diff --git a/src/views/order/myOrders/index.vue b/src/views/order/myOrders/index.vue index 800c1a4..750680a 100644 --- a/src/views/order/myOrders/index.vue +++ b/src/views/order/myOrders/index.vue @@ -34,6 +34,7 @@ type OrderDownstreamForwardResult, } from '@/api/order'; import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow'; + import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide'; import OrderDetailDrawer from './components/OrderDetailDrawer.vue'; import OrderPayConfirmModal from './components/OrderPayConfirmModal.vue'; import MarkProblematicModal from './components/MarkProblematicModal.vue'; @@ -1165,8 +1166,11 @@ if (colKey === 'predeclareFailReason') { const reason = rowData?.predeclareFailReason || value || ''; if (!reason) return null; - const escaped = escapeHtml(String(reason)); - return `${escaped}`; + const guide = buildFailGuideFromReason(String(reason)); + const display = guide.reason || String(reason); + const escaped = escapeHtml(display); + const title = escapeHtml(guide.rawReason || String(reason)); + return `${escaped}`; } if (colKey === 'allocationPendingReason') { const reason = rowData?.allocationPendingReason || value || ''; @@ -1239,12 +1243,31 @@ }; onMounted(() => { - nextTick(() => { - loadOrderList(); + nextTick(async () => { + await loadOrderList(); loadTabCounts(); + await tryOpenOrderFromQuery(); }); }); + async function tryOpenOrderFromQuery() { + const rawId = route.query.orderId; + const idStr = Array.isArray(rawId) ? rawId[0] : rawId; + const id = Number(idStr); + if (!Number.isFinite(id) || id <= 0) return; + const editRaw = route.query.edit; + const editStr = Array.isArray(editRaw) ? editRaw[0] : editRaw; + const editBasic = String(editStr || '') === '1'; + try { + await openDetailWithOptionalFetch({ id }, { editBasic }); + } finally { + const nextQuery = { ...route.query } as Record; + delete nextQuery.orderId; + delete nextQuery.edit; + router.replace({ query: nextQuery }); + } + } + watch( () => route.query.status, () => { @@ -1260,6 +1283,14 @@ loadTabCounts(); }, ); + + watch( + () => route.query.orderId, + (orderId) => { + if (!orderId) return; + void tryOpenOrderFromQuery(); + }, + );