feat: 自动触发支持 AI 写公式,并完善下单失败与订单附件体验

触发条件接入 AI 助手(选模型、发送 skill、一键填入);预报时机并入下单并支持公式验证;下单失败可继续修改或存草稿;补充订单附件、H5 下单与渠道买价相关优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-28 20:54:28 +08:00
parent 8a9d900628
commit a2e71a3a6b
28 changed files with 4711 additions and 339 deletions

View File

@ -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)

View File

@ -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` |

View File

@ -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 |

View File

@ -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;批量标记问题件 + 问题模板 |

View File

@ -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<string, any>
return {
matched: Boolean(data?.matched ?? data?.Matched),
expression: data?.expression ?? data?.Expression,
contextType: data?.contextType ?? data?.ContextType,
error: data?.error ?? data?.Error,
}
}

View File

@ -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<void> {
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<string, any>): Promise<any> {
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<OrderSto
assertBusinessOk(res.data)
return unwrapData(res.data)
}
export interface OrderFileItem {
id: number
orderId: number
fileType: string
fileName?: string
fileUrl: string
contentType?: string
fileSize?: number
sort?: number
source?: string
remark?: string
createTime?: string
}
export interface OrderFileGroupedResult {
orderId: number
groups: Record<string, OrderFileItem[]>
}
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<OrderFileGroupedResult> {
const res = await axiosInstance.get(orderUrl('/order/files'), { params: { orderId } })
assertBusinessOk(res.data)
const data = unwrapData<any>(res.data)
const rawGroups = data?.groups ?? data?.Groups ?? {}
const groups: Record<string, OrderFileItem[]> = {}
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<OrderFileItem[]> {
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<any>(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<void> {
const res = await axiosInstance.post(orderUrl('/order/files/delete'), { id })
assertBusinessOk(res.data)
}

View File

@ -185,11 +185,25 @@
const selectedRowIndicesSet = ref<Set<number>>(new Set());
const internalColumnOrder = ref<string[]>([]);
/** 避免 `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;

View File

@ -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',

View File

@ -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': '新增',

View File

@ -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<string, unknown>
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 || '') })
}

View File

@ -0,0 +1,563 @@
<template>
<a-drawer
:visible="visible"
:width="520"
unmount-on-close
:mask-closable="!loading"
@cancel="close"
@update:visible="onVisibleChange"
>
<template #title>
<div class="drawer-title">
<span>{{ t('channel.autoTrigger.ai.title') }}</span>
<a class="config-link" href="javascript:;" @click="goConfig">
{{ t('channel.autoTrigger.ai.configLink') }}
</a>
</div>
</template>
<div class="ai-assist-body">
<p class="intro">{{ t('channel.autoTrigger.ai.intro') }}</p>
<div class="row-fields">
<div class="field">
<label class="label required">{{ t('channel.autoTrigger.ai.provider') }}</label>
<a-select
v-model="providerKey"
:placeholder="t('channel.autoTrigger.ai.providerPlaceholder')"
:loading="providersLoading"
allow-search
@change="onProviderChange"
>
<a-option
v-for="p in enabledProviders"
:key="p.providerKey"
:value="p.providerKey"
:label="p.displayName || p.providerKey"
/>
</a-select>
</div>
<div class="field">
<label class="label required">{{ t('channel.autoTrigger.ai.model') }}</label>
<a-select
v-model="modelId"
:placeholder="t('channel.autoTrigger.ai.modelPlaceholder')"
allow-create
allow-search
>
<a-option v-for="m in modelOptions" :key="m.modelId" :value="m.modelId">
{{ m.modelId }}{{ m.role ? `(${m.role})` : '' }}
</a-option>
</a-select>
</div>
</div>
<div class="skill-row">
<a-checkbox v-model="sendSkill">
{{ t('channel.autoTrigger.ai.sendSkill') }}
</a-checkbox>
<span class="skill-hint">{{ t('channel.autoTrigger.ai.sendSkillHint') }}</span>
</div>
<div class="field">
<label class="label required">{{ t('channel.autoTrigger.ai.prompt') }}</label>
<a-textarea
v-model="prompt"
:placeholder="t('channel.autoTrigger.ai.promptPlaceholder')"
:auto-size="{ minRows: 4, maxRows: 8 }"
:disabled="loading"
@keydown.ctrl.enter.exact.prevent="generate"
/>
<div class="quick-prompts">
<button
v-for="(q, i) in FORMULA_AI_QUICK_PROMPTS"
:key="i"
type="button"
class="quick-chip"
:disabled="loading"
@click="prompt = q"
>
{{ shortQuick(q) }}
</button>
</div>
</div>
<div class="actions">
<a-button type="primary" :loading="loading" :disabled="!canSend" @click="generate">
{{ loading ? t('channel.autoTrigger.ai.generating') : t('channel.autoTrigger.ai.generate') }}
</a-button>
<a-button :disabled="loading" @click="clearResult">
{{ t('channel.autoTrigger.ai.clear') }}
</a-button>
</div>
<div v-if="errorMsg" class="error-box">{{ errorMsg }}</div>
<div v-if="parsed || rawReply" class="result-block">
<div class="section-label">{{ t('channel.autoTrigger.ai.result') }}</div>
<div v-if="parsed?.expression" class="formula-preview">
<div class="meta-line" v-if="parsed.timingLabel || parsed.operationLabel">
<a-tag v-if="parsed.timingLabel" size="small" color="arcoblue">
{{ parsed.timingLabel }}
</a-tag>
<a-tag v-if="parsed.operationLabel" size="small" color="orangered">
{{ parsed.operationLabel }}
</a-tag>
</div>
<pre class="formula-code">{{ parsed.expression }}</pre>
<p v-if="parsed.explanation" class="explain">{{ parsed.explanation }}</p>
<p v-if="parsed.sampleHint" class="sample">
{{ t('channel.autoTrigger.ai.sampleHint') }}:{{ parsed.sampleHint }}
</p>
</div>
<details v-if="rawReply" class="raw-details">
<summary>{{ t('channel.autoTrigger.ai.rawReply') }}</summary>
<pre class="raw-pre">{{ rawReply }}</pre>
</details>
</div>
</div>
<template #footer>
<div class="footer-actions">
<a-button @click="close">{{ t('common.cancel') }}</a-button>
<a-button
type="primary"
:disabled="!parsed?.expression"
@click="applyFormula"
>
{{ t('channel.autoTrigger.ai.fillFormula') }}
</a-button>
</div>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
import {
providerModelTags,
type AiProviderItem,
} from '@/views/system/xhsAiAssistant/providerCatalog'
import {
FORMULA_AI_QUICK_PROMPTS,
buildFormulaAiSystemPrompt,
parseAiFormulaReply,
type ParsedAiFormula,
} from '../formulaAiSkill'
const STORAGE_KEY = 'oms.autoTrigger.formulaAi'
const props = defineProps<{
visible: boolean
/** 当前公式,生成时一并作为上下文 */
currentFormula?: string
timingLabel?: string
operationLabel?: string
}>()
const emit = defineEmits<{
'update:visible': [boolean]
apply: [payload: {
expression: string
timingLabel?: string
operationLabel?: string
sampleHint?: string
}]
}>()
const { t } = useI18n()
const router = useRouter()
const providersLoading = ref(false)
const providers = ref<AiProviderItem[]>([])
const providerKey = ref('')
const modelId = ref('')
const sendSkill = ref(true)
const prompt = ref('')
const loading = ref(false)
const errorMsg = ref('')
const rawReply = ref('')
const parsed = ref<ParsedAiFormula | null>(null)
const enabledProviders = computed(() =>
providers.value.filter((p) => p.enabled && Boolean(p.apiKey)),
)
const selectedProvider = computed(
() => enabledProviders.value.find((p) => p.providerKey === providerKey.value) ?? null,
)
const modelOptions = computed(() => {
const p = selectedProvider.value
if (!p) return []
const tags = providerModelTags(p.providerType)
const extra = String(p.apiSecret || '').trim()
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }]
}
return tags
})
const canSend = computed(
() =>
Boolean(providerKey.value && modelId.value && prompt.value.trim()) && !loading.value,
)
function shortQuick(q: string) {
return q.length > 22 ? `${q.slice(0, 22)}…` : q
}
function onVisibleChange(v: boolean) {
emit('update:visible', v)
}
function close() {
emit('update:visible', false)
}
function goConfig() {
router.push({ path: '/system/xhsAiAssistant' })
}
function loadPrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as { providerKey?: string; modelId?: string; sendSkill?: boolean }
if (data.providerKey) providerKey.value = data.providerKey
if (data.modelId) modelId.value = data.modelId
if (typeof data.sendSkill === 'boolean') sendSkill.value = data.sendSkill
} catch {
/* ignore */
}
}
function savePrefs() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
providerKey: providerKey.value,
modelId: modelId.value,
sendSkill: sendSkill.value,
}),
)
} catch {
/* ignore */
}
}
function onProviderChange() {
const opts = modelOptions.value
if (!opts.some((m) => m.modelId === modelId.value)) {
modelId.value = opts[0]?.modelId ?? ''
}
savePrefs()
}
async function loadProviders() {
providersLoading.value = true
try {
const items = await listXhsAiProviders()
providers.value = (items ?? []) as AiProviderItem[]
if (!providerKey.value) {
const def =
enabledProviders.value.find((p) => p.isDefault) ?? enabledProviders.value[0]
if (def) providerKey.value = def.providerKey
}
if (providerKey.value && !enabledProviders.value.some((p) => p.providerKey === providerKey.value)) {
providerKey.value = enabledProviders.value[0]?.providerKey ?? ''
}
onProviderChange()
} catch (e: any) {
Message.error(e?.message || t('channel.autoTrigger.ai.loadProvidersFail'))
} finally {
providersLoading.value = false
}
}
function clearResult() {
rawReply.value = ''
parsed.value = null
errorMsg.value = ''
}
async function generate() {
if (!canSend.value) {
Message.warning(t('channel.autoTrigger.ai.needProviderModelPrompt'))
return
}
savePrefs()
loading.value = true
errorMsg.value = ''
rawReply.value = ''
parsed.value = null
const ctxParts: string[] = []
if (props.timingLabel) ctxParts.push(`当前时机:${props.timingLabel}`)
if (props.operationLabel) ctxParts.push(`当前操作:${props.operationLabel}`)
if (props.currentFormula?.trim()) {
ctxParts.push(`当前公式(可改写):\n${props.currentFormula.trim()}`)
}
const userContent = [
ctxParts.length ? `【上下文】\n${ctxParts.join('\n')}` : '',
`【需求】\n${prompt.value.trim()}`,
'请按 skill 规定的格式输出(时机/操作/公式/说明)。',
]
.filter(Boolean)
.join('\n\n')
const messages: { role: string; content: string }[] = []
if (sendSkill.value) {
messages.push({ role: 'system', content: buildFormulaAiSystemPrompt() })
} else {
messages.push({
role: 'system',
content:
'你是 OMS 自动触发公式助手。只输出中文公式表达式;字符串用 字段包含(\'…\'),数值用 字段() 比较。',
})
}
messages.push({ role: 'user', content: userContent })
try {
const res = await chatXhsAi({
providerKey: providerKey.value,
model: modelId.value,
messages,
scene: 'auto_trigger_formula',
})
const content = String(res?.content ?? '').trim()
if (!content) {
errorMsg.value = t('channel.autoTrigger.ai.emptyReply')
return
}
rawReply.value = content
const result = parseAiFormulaReply(content)
parsed.value = result
if (!result.expression) {
errorMsg.value = t('channel.autoTrigger.ai.parseFail')
} else {
Message.success(t('channel.autoTrigger.ai.generateOk'))
}
} catch (e: any) {
errorMsg.value = e?.message || t('channel.autoTrigger.ai.generateFail')
} finally {
loading.value = false
}
}
function applyFormula() {
if (!parsed.value?.expression) return
emit('apply', {
expression: parsed.value.expression,
timingLabel: parsed.value.timingLabel,
operationLabel: parsed.value.operationLabel,
sampleHint: parsed.value.sampleHint,
})
Message.success(t('channel.autoTrigger.ai.filled'))
close()
}
watch(
() => props.visible,
async (v) => {
if (!v) return
loadPrefs()
await loadProviders()
},
)
watch(sendSkill, savePrefs)
watch(modelId, savePrefs)
</script>
<style scoped lang="less">
.drawer-title {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding-right: 8px;
.config-link {
margin-left: auto;
font-size: 13px;
font-weight: 400;
color: var(--app-primary-color, #2d8c8c);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.ai-assist-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.intro {
margin: 0;
font-size: 13px;
color: #86909c;
line-height: 1.5;
}
.row-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
color: #4e5969;
font-weight: 500;
&.required::before {
content: '*';
color: #f53f3f;
margin-right: 4px;
}
}
.skill-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
.skill-hint {
font-size: 12px;
color: #86909c;
}
}
.quick-prompts {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.quick-chip {
height: 28px;
padding: 0 10px;
border: 1px solid #e5e6eb;
border-radius: 4px;
background: #f7f8fa;
color: #4e5969;
font-size: 12px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--app-primary-color, #2d8c8c);
color: var(--app-primary-color, #2d8c8c);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.actions {
display: flex;
gap: 8px;
}
.error-box {
padding: 8px 12px;
border-radius: 4px;
background: #ffece8;
color: #cb272d;
font-size: 13px;
line-height: 1.5;
}
.result-block {
border-top: 1px solid #e5e6eb;
padding-top: 12px;
}
.section-label {
font-size: 13px;
font-weight: 500;
color: #4e5969;
margin-bottom: 8px;
}
.formula-preview {
background: #f7f8fa;
border: 1px solid #e5e6eb;
border-radius: 6px;
padding: 12px;
}
.meta-line {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.formula-code {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.55;
color: #1d2129;
}
.explain,
.sample {
margin: 8px 0 0;
font-size: 12px;
color: #86909c;
line-height: 1.5;
}
.raw-details {
margin-top: 10px;
font-size: 12px;
color: #4e5969;
summary {
cursor: pointer;
user-select: none;
}
}
.raw-pre {
margin: 8px 0 0;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 8px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@ -1,22 +1,36 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
FORMULA_MAX_LENGTH,
FORMULA_OPERATORS,
ORDER_TABLE_CATEGORIES,
chipsByTable,
} from '../formulaMeta'
import FormulaAiAssistDrawer from './FormulaAiAssistDrawer.vue'
const props = defineProps<{
modelValue: string
timingLabel?: string
operationLabel?: string
}>()
const emit = defineEmits<{
'update:modelValue': [string]
'ai-apply': [
payload: {
expression: string
timingLabel?: string
operationLabel?: string
sampleHint?: string
},
]
}>()
const { t } = useI18n()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const activeCategory = ref<string>('t_order')
const aiVisible = ref(false)
const charCount = computed(() => props.modelValue.length)
const activeChips = computed(() => chipsByTable(activeCategory.value))
@ -26,6 +40,16 @@ function updateValue(next: string) {
emit('update:modelValue', clipped)
}
function onAiApply(payload: {
expression: string
timingLabel?: string
operationLabel?: string
sampleHint?: string
}) {
updateValue(payload.expression.slice(0, FORMULA_MAX_LENGTH))
emit('ai-apply', payload)
}
async function insertAtCursor(text: string) {
const el = textareaRef.value
if (!el) {
@ -63,6 +87,13 @@ defineExpose({ insertAtCursor })
<template>
<div class="formula-condition-editor">
<div class="editor-toolbar">
<span class="toolbar-hint">{{ t('channel.autoTrigger.ai.toolbarHint') }}</span>
<button type="button" class="ai-btn" @click="aiVisible = true">
{{ t('channel.autoTrigger.ai.open') }}
</button>
</div>
<div class="formula-text-wrap">
<textarea
ref="textareaRef"
@ -76,6 +107,14 @@ defineExpose({ insertAtCursor })
<div class="char-count">{{ charCount }}/{{ FORMULA_MAX_LENGTH }}</div>
</div>
<FormulaAiAssistDrawer
v-model:visible="aiVisible"
:current-formula="modelValue"
:timing-label="timingLabel"
:operation-label="operationLabel"
@apply="onAiApply"
/>
<div class="operator-section">
<div class="section-label">运算符</div>
<div class="operator-bar">
@ -135,6 +174,37 @@ defineExpose({ insertAtCursor })
width: 100%;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.toolbar-hint {
font-size: 12px;
color: #86909c;
line-height: 1.4;
}
.ai-btn {
flex-shrink: 0;
height: 32px;
padding: 0 12px;
border: 1px solid var(--app-primary-color, #2d8c8c);
border-radius: 4px;
background: rgba(45, 140, 140, 0.08);
color: var(--app-primary-color, #2d8c8c);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--app-primary-color, #2d8c8c);
color: #fff;
}
}
.formula-text-wrap {
position: relative;

View File

@ -0,0 +1,144 @@
import {
ORDER_TABLE_FIELDS,
VIRTUAL_FORMULA_FIELDS,
type OrderTableFieldDef,
} from './formulaMeta'
/** 与 .cursor/skills/oms-auto-trigger-formula 对齐的运行时 skill(发给 AI 的 system) */
export type ParsedAiFormula = {
expression: string
timingLabel?: string
operationLabel?: string
explanation?: string
sampleHint?: string
raw: string
}
function fieldUsage(def: OrderTableFieldDef): string {
if (def.valueType === 'string' || def.valueType === 'datetime') {
return `${def.label}包含('…')`
}
if (def.valueType === 'boolean') {
return `${def.label}()==1`
}
return `${def.label}()`
}
function buildFieldCatalog(): string {
const all = [...ORDER_TABLE_FIELDS, ...VIRTUAL_FORMULA_FIELDS]
const byTable = new Map<string, OrderTableFieldDef[]>()
for (const f of all) {
const list = byTable.get(f.table) ?? []
list.push(f)
byTable.set(f.table, list)
}
const lines: string[] = []
for (const [table, fields] of byTable) {
lines.push(`### ${table}`)
for (const f of fields) {
const virt = f.virtual ? ' [聚合]' : ''
lines.push(`- ${f.label} (${f.valueType}${virt}) → ${fieldUsage(f)}`)
}
}
return lines.join('\n')
}
const SKILL_CORE = `你是 OMS「自动触发」公式助手。根据用户中文描述,生成可粘贴的中文公式(ChineseFormulaEvaluator)。
## 产出格式(必须)
时机:下单 | 入库 | 出库
操作:拦截 | 警告 | 问题件
公式:单行可执行表达式
说明:一句话
验证样例:可选
## 语法硬规则
- 字段名必须是下方目录中的中文 label,禁止编造、禁止英文字段名
- 字符串:字段包含('值');数值/聚合:字段() 再比较;布尔:字段()==1 / ==0
- 字面量用单引号;逻辑用 && || !;比较用 == != > >= < <=
- 禁止 and/or/not、中文「且」、双引号、字段() 用于字符串相等
- 国家一般用二字码:国家包含('GB')
- 公式单行、无注释夹杂、长度 ≤ 10000
## 示例
时机:下单
操作:拦截
公式:(单件最大长度()>60||单件最大宽度()>46)&&国家包含('GB')
说明:英国且超尺寸时拦截
验证样例:国家 GB,长 70,宽 40,高 40
`
let cachedSystemPrompt: string | null = null
/** 完整 skill system prompt(含字段目录) */
export function buildFormulaAiSystemPrompt(): string {
if (cachedSystemPrompt) return cachedSystemPrompt
cachedSystemPrompt = `${SKILL_CORE}\n\n## 可用字段目录(仅可使用下列中文名)\n\n${buildFieldCatalog()}`
return cachedSystemPrompt
}
/** 从 AI 回复中解析公式块 */
export function parseAiFormulaReply(raw: string): ParsedAiFormula {
const text = String(raw || '').trim()
const timing =
text.match(/时机[::]\s*(下单|入库|出库|预报)/)?.[1] ??
undefined
const operation =
text.match(/操作[::]\s*(拦截|警告|问题件)/)?.[1] ??
undefined
const explanation =
text.match(/说明[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
undefined
const sampleHint =
text.match(/验证样例[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
undefined
let expression =
text.match(/公式[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ??
''
if (!expression) {
// 尝试取 fenced code 或首行像公式的内容
const fence = text.match(/```(?:\w*)?\s*\n?([\s\S]*?)```/)
if (fence?.[1]) {
const inner = fence[1].trim()
expression =
inner.match(/公式[::]\s*(.+?)(?:\n|$)/)?.[1]?.trim() ||
inner.split('\n').find((l) => /包含\(|\(\)|&&|\|\|/.test(l))?.trim() ||
''
}
}
if (!expression) {
const candidate = text
.split('\n')
.map((l) => l.trim())
.find((l) => /包含\(|\(\)|&&|\|\|/.test(l) && !/^(时机|操作|说明|验证)/.test(l))
expression = candidate || ''
}
// 去掉可能的围栏残片
expression = expression.replace(/^`+|`+$/g, '').trim()
const timingLabel = timing === '预报' ? '下单' : timing
return {
expression,
timingLabel,
operationLabel: operation,
explanation,
sampleHint,
raw: text,
}
}
export const FORMULA_AI_QUICK_PROMPTS = [
'英国超尺寸拦截:单边超 60/46 或三边和超 150,国家 GB,排除产品 EX_ASP_HKDHL_YD',
'偏远地区下单时警告',
'申报金额 ≥ 2000 且国家 AU 时记问题件',
'敏感货且收货渠道含 DHL 时拦截',
]

View File

@ -286,7 +286,6 @@ export const OVERSIZE_EXAMPLE_FORMULA =
export const FORMULA_MAX_LENGTH = 10000
export const TIMING_SAVE_LABEL: Record<string, string> = {
forecast: '预报',
order_create: '下单',
inbound: '入库',
outbound: '出库',

View File

@ -14,6 +14,7 @@ import {
createAutoTrigger,
deleteAutoTrigger,
getAutoTriggerList,
testAutoTriggerFormula,
toggleAutoTriggerStatus,
updateAutoTrigger,
} from '@/api/channel/autoTrigger'
@ -149,16 +150,23 @@ async function loadDictOptions() {
}))
.filter((o) => o.label && o.value)
timingOptions.value = mapDict(timingItems)
if (!timingOptions.value.some((o) => o.value === 'forecast')) {
timingOptions.value.unshift({ label: '预报', value: 'forecast' })
timingOptions.value = mapDict(timingItems).filter(
(o) => o.value !== 'forecast' && o.label !== '预报',
)
if (!timingOptions.value.some((o) => o.value === 'order_create')) {
timingOptions.value.unshift({ label: '下单', value: 'order_create' })
}
if (!timingOptions.value.some((o) => o.value === 'inbound')) {
timingOptions.value.push({ label: '入库', value: 'inbound' })
}
if (!timingOptions.value.some((o) => o.value === 'outbound')) {
timingOptions.value.push({ label: '出库', value: 'outbound' })
}
operationOptions.value = mapDict(operationItems)
} catch (e: any) {
$baseMessage(e?.message || '加载字典失败', 'error')
timingOptions.value = [
{ label: '预报', value: 'forecast' },
{ label: '下单', value: 'order_create' },
{ label: '入库', value: 'inbound' },
{ label: '出库', value: 'outbound' },
@ -173,7 +181,8 @@ async function loadDictOptions() {
function resolveTimingCode(saved: string): string {
const s = String(saved ?? '').trim()
if (!s) return timingOptions.value[0]?.value ?? 'forecast'
if (!s) return timingOptions.value[0]?.value ?? 'order_create'
if (s === 'forecast' || s === '预报' || s === '下单' || s === 'order_create') return 'order_create'
const byValue = timingOptions.value.find((o) => o.value === s)
if (byValue) return byValue.value
const byLabel = timingOptions.value.find((o) => o.label === s)
@ -194,8 +203,9 @@ function resolveOperationCode(saved: string): string {
}
function timingLabel(code: string): string {
return timingOptions.value.find((o) => o.value === code)?.label
|| TIMING_SAVE_LABEL[code]
const normalized = resolveTimingCode(code)
return timingOptions.value.find((o) => o.value === normalized)?.label
|| TIMING_SAVE_LABEL[normalized]
|| code
}
@ -221,6 +231,23 @@ const formulaText = ref('')
const formulaEditorRef = ref<InstanceType<typeof FormulaConditionEditor> | null>(null)
const notifierUserRef = ref<InstanceType<typeof SelectPro> | null>(null)
const formulaEditorTimingLabel = computed(() => timingLabel(formModel.timingCode))
const formulaEditorOperationLabel = computed(() => operationLabel(formModel.operationCode))
function onFormulaAiApply(payload: {
expression: string
timingLabel?: string
operationLabel?: string
sampleHint?: string
}) {
if (payload.timingLabel) {
formModel.timingCode = resolveTimingCode(payload.timingLabel)
}
if (payload.operationLabel) {
formModel.operationCode = resolveOperationCode(payload.operationLabel)
}
}
async function getRbacUserList(params: { pageIndex?: number; pageSize?: number; keyword?: string }) {
const res = await UserService.getRbacUserGetList(
params.keyword,
@ -272,22 +299,118 @@ const formModel = reactive({
status: true,
ruleName: '',
operationCode: 'intercept',
timingCode: 'forecast',
timingCode: 'order_create',
promptMessage: '',
notifierIds: [] as string[],
})
/** 触发条件真伪验证:样例 / 真实订单 */
const testMode = ref<'sample' | 'order'>('sample')
const testLoading = ref(false)
const testOrderId = ref<string>('')
const testResult = ref<{
matched?: boolean
error?: string
contextType?: string
} | null>(null)
const testSample = reactive({
consigneeCountry: 'GB',
consigneeCountryName: '英国',
customerName: '测试客户',
productCode: '',
clientNo: 'TEST-001',
length: 60 as number | undefined,
width: 40 as number | undefined,
height: 40 as number | undefined,
actualWeight: 5 as number | undefined,
})
function resetFormModel() {
formulaText.value = ''
legacyConditionText.value = ''
testResult.value = null
testMode.value = 'sample'
testOrderId.value = ''
Object.assign(formModel, {
status: true,
ruleName: '',
operationCode: 'intercept',
timingCode: 'forecast',
timingCode: 'order_create',
promptMessage: '',
notifierIds: [],
})
Object.assign(testSample, {
consigneeCountry: 'GB',
consigneeCountryName: '英国',
customerName: '测试客户',
productCode: '',
clientNo: 'TEST-001',
length: 60,
width: 40,
height: 40,
actualWeight: 5,
})
}
async function handleTestFormula() {
const validateErr = validateFormulaExpression(formulaText.value)
if (validateErr) {
$baseMessage(validateErr, 'warning')
return
}
if (!String(formulaText.value || '').trim()) {
$baseMessage(t('channel.autoTrigger.form.testNeedFormula'), 'warning')
return
}
testLoading.value = true
testResult.value = null
try {
const body: Parameters<typeof testAutoTriggerFormula>[0] = {
expression: String(formulaText.value).trim(),
}
if (testMode.value === 'order') {
const oid = Number(String(testOrderId.value || '').trim())
if (!Number.isFinite(oid) || oid <= 0) {
$baseMessage(t('channel.autoTrigger.form.testNeedOrderId'), 'warning')
return
}
body.orderId = oid
} else {
body.sample = {
consigneeCountry: String(testSample.consigneeCountry || '').trim() || undefined,
consigneeCountryName: String(testSample.consigneeCountryName || '').trim() || undefined,
customerName: String(testSample.customerName || '').trim() || undefined,
productCode: String(testSample.productCode || '').trim() || undefined,
clientNo: String(testSample.clientNo || '').trim() || undefined,
length: testSample.length,
width: testSample.width,
height: testSample.height,
actualWeight: testSample.actualWeight,
}
}
const raw = await testAutoTriggerFormula(body)
const matched = Boolean((raw as any)?.matched ?? (raw as any)?.Matched)
const error = String((raw as any)?.error ?? (raw as any)?.Error ?? '').trim()
const contextType = String((raw as any)?.contextType ?? (raw as any)?.ContextType ?? '')
testResult.value = { matched, error: error || undefined, contextType }
if (error) {
$baseMessage(error || t('channel.autoTrigger.form.testFailed'), 'error')
} else {
$baseMessage(
matched
? t('channel.autoTrigger.form.testMatched')
: t('channel.autoTrigger.form.testNotMatched'),
matched ? 'success' : 'warning',
)
}
} catch (e: any) {
testResult.value = { matched: false, error: e?.message || t('channel.autoTrigger.form.testFailed') }
$baseMessage(e?.message || t('channel.autoTrigger.form.testFailed'), 'error')
} finally {
testLoading.value = false
}
}
async function openAddModal() {
@ -304,6 +427,9 @@ async function openEditModal(row: AutoTriggerRow) {
const parsed = parseStoredCondition(row.triggerCondition)
formulaText.value = parsed.expression
legacyConditionText.value = parsed.legacyText || ''
testResult.value = null
testMode.value = 'sample'
testOrderId.value = ''
Object.assign(formModel, {
status: row.status,
ruleName: parsed.ruleName || row.ruleName || '',
@ -982,6 +1108,63 @@ onMounted(() => {
}
}
.test-section {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
background: #f7f8fa;
border: 1px solid #e5e6eb;
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.form-label {
margin: 0;
}
}
}
.test-hint {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #86909c;
}
.test-sample-grid {
margin-top: 2px;
}
.test-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.test-result {
font-size: 13px;
font-weight: 600;
&.is-matched {
color: #00b42a;
}
&.is-miss {
color: #ff7d00;
}
&.is-error {
color: #f53f3f;
font-weight: 500;
}
}
</style>
<template>
@ -1116,7 +1299,91 @@ onMounted(() => {
<div class="condition-section">
<label class="form-label required">{{ t('channel.autoTrigger.table.triggerCondition') }}</label>
<FormulaConditionEditor ref="formulaEditorRef" v-model="formulaText" />
<FormulaConditionEditor
ref="formulaEditorRef"
v-model="formulaText"
:timing-label="formulaEditorTimingLabel"
:operation-label="formulaEditorOperationLabel"
@ai-apply="onFormulaAiApply"
/>
</div>
<div class="test-section">
<div class="section-head">
<label class="form-label">{{ t('channel.autoTrigger.form.testFormula') }}</label>
<a-radio-group v-model="testMode" type="button" size="small">
<a-radio value="sample">{{ t('channel.autoTrigger.form.testSample') }}</a-radio>
<a-radio value="order">{{ t('channel.autoTrigger.form.testByOrder') }}</a-radio>
</a-radio-group>
</div>
<p class="test-hint">{{ t('channel.autoTrigger.form.testHint') }}</p>
<div v-if="testMode === 'sample'" class="form-grid test-sample-grid">
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCountry') }}</label>
<a-input v-model="testSample.consigneeCountry" placeholder="GB" allow-clear />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCountryName') }}</label>
<a-input v-model="testSample.consigneeCountryName" placeholder="英国" allow-clear />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleCustomer') }}</label>
<a-input v-model="testSample.customerName" allow-clear />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleProduct') }}</label>
<a-input v-model="testSample.productCode" allow-clear />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleLength') }}</label>
<a-input-number v-model="testSample.length" :min="0" :precision="2" style="width: 100%" />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleWidth') }}</label>
<a-input-number v-model="testSample.width" :min="0" :precision="2" style="width: 100%" />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleHeight') }}</label>
<a-input-number v-model="testSample.height" :min="0" :precision="2" style="width: 100%" />
</div>
<div class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.sampleWeight') }}</label>
<a-input-number v-model="testSample.actualWeight" :min="0" :precision="3" style="width: 100%" />
</div>
</div>
<div v-else class="form-row">
<label class="form-label">{{ t('channel.autoTrigger.form.testOrderId') }}</label>
<a-input
v-model="testOrderId"
:placeholder="t('channel.autoTrigger.form.testOrderIdPlaceholder')"
allow-clear
/>
</div>
<div class="test-actions">
<a-button type="outline" :loading="testLoading" @click="handleTestFormula">
{{ t('channel.autoTrigger.form.testRun') }}
</a-button>
<span
v-if="testResult"
class="test-result"
:class="{
'is-matched': testResult.matched && !testResult.error,
'is-miss': testResult.matched === false && !testResult.error,
'is-error': !!testResult.error,
}"
>
<template v-if="testResult.error">{{ testResult.error }}</template>
<template v-else-if="testResult.matched">
{{ t('channel.autoTrigger.form.testMatched') }}
</template>
<template v-else>
{{ t('channel.autoTrigger.form.testNotMatched') }}
</template>
</span>
</div>
</div>
<div class="form-grid">

View File

@ -254,7 +254,7 @@
}
}
// 客户面单:真实标签模板下拉
// 系统面单:真实标签模板下拉
const labelTemplateOptions = ref<{ label: string; value: number }[]>([]);
async function loadLabelTemplateOptions() {
@ -269,10 +269,14 @@
}
}
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
return { label: v, value: v };
});
/** 重量因素值:0.5 步进至 30,其后固定 999 */
const weightFactorOptions: IdSelectOption[] = [
...Array.from({ length: 60 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
return { label: v, value: v };
}),
{ label: '999', value: '999' },
];
function filterKpiAreasByType(items: any[], areaType: string) {
const want = areaType.toLowerCase();
@ -1864,6 +1868,8 @@
billingFactorsData.value = getDefaultBillingFactorsData().map(item => ({ ...item }));
// 重置价格表数据
priceListData.value = [];
priceListCustomerCodesSnapshot.value = [];
priceListSelectedIds.value = [];
priceDrawerActiveTab.value = 'basic';
priceModalVisible.value = true;
@ -2034,6 +2040,48 @@
// 价格表数据
const priceListData = ref<any[]>([]);
/** 当前价格表行对应的客户组编码顺序(用于同步时按编码保留价格) */
const priceListCustomerCodesSnapshot = ref<string[]>([]);
const priceListSelectedIds = ref<number[]>([]);
const batchFillModalVisible = ref(false);
const batchFillValue = ref<number | undefined>(undefined);
const batchFillOnlyEmpty = ref(false);
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001');
}
function capturePriceListCustomerCodesSnapshot() {
priceListCustomerCodesSnapshot.value = getSelectedCustomerGroupCodesFromBilling();
}
function buildEmptyCustomerPrices(codes: string[]): Record<string, number> {
const out: Record<string, number> = {};
codes.forEach((_, i) => {
out[`customer_${i}`] = 0;
});
return out;
}
function extractCustomerPricesByCode(row: any, codes: string[]): Record<string, number> {
const map: Record<string, number> = {};
codes.forEach((code, i) => {
const raw = row?.[`customer_${i}`];
const n = Number(raw);
map[code] = Number.isFinite(n) ? n : 0;
});
return map;
}
function applyCustomerPricesByCode(
row: any,
newCodes: string[],
priceByCode: Record<string, number>
) {
newCodes.forEach((code, i) => {
row[`customer_${i}`] = priceByCode[code] ?? 0;
});
}
// 价格表列顺序(表头):[ 非客户计费因素列… ] → [ 计算公式 ] → [ 客户组列… ]
// 客户组列表头为 customer_group.dictName,且整段客户组列紧跟在「计算公式」列之后(右侧)
@ -2098,49 +2146,63 @@
return [...factorHeaders, formulaHeader, ...customerGroupHeaders];
});
// 生成价格表数据(根据计费因素和因素值)
const generatePriceListData = () => {
const newData: any[] = [];
// 获取非客户因素
// 按计费因素同步价格表:已有组合保留价格,仅补新增行
const syncPriceListData = (opts?: { dropMissing?: boolean }) => {
const dropMissing = opts?.dropMissing !== false;
const nonCustomerFactors = billingFactorsData.value.filter(
(item) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
);
if (nonCustomerFactors.length === 0) {
Message.warning('请至少配置一个非客户因素');
return;
return null;
}
// 生成所有因素值的组合(笛卡尔积)
const generateCombinations = (arrays: string[][]): string[][] => {
if (arrays.length === 0) return [[]];
if (arrays.length === 1) return arrays[0].map((item) => [item]);
const [first, ...rest] = arrays;
const restCombinations = generateCombinations(rest);
const result: string[][] = [];
for (const firstItem of first) {
for (const restCombination of restCombinations) {
result.push([firstItem, ...restCombination]);
}
}
return result;
};
const factorValueArrays = nonCustomerFactors.map((factor) => factor.factorValues);
const combinations = generateCombinations(factorValueArrays);
// 为每个组合创建一行数据
const factorColKeys = nonCustomerFactors.map((factor) => {
const colIdx = billingFactorsData.value.findIndex((item) => item.id === factor.id);
return `factor_${colIdx}`;
});
const oldCodes = priceListCustomerCodesSnapshot.value.length
? [...priceListCustomerCodesSnapshot.value]
: getSelectedCustomerGroupCodesFromBilling();
const newCodes = getSelectedCustomerGroupCodesFromBilling();
const oldByKey = new Map<string, any>();
for (const row of priceListData.value) {
const key = buildPriceRowFactorKey(row, factorColKeys);
if (!key || key.split('\u0001').every((p) => !p)) continue;
if (!oldByKey.has(key)) oldByKey.set(key, row);
}
const newData: any[] = [];
let kept = 0;
let added = 0;
combinations.forEach((combination, index) => {
const row: any = {
id: index + 1,
calculationFormula: defaultCalculationFormulaCode(),
...buildEmptyCustomerPrices(newCodes),
};
// 设置因素值(重量转为左开右闭区间,如 (0-0.5])
nonCustomerFactors.forEach((factor, factorIndex) => {
const colIdx = billingFactorsData.value.findIndex((item) => item.id === factor.id);
const raw = combination[factorIndex];
@ -2148,25 +2210,69 @@
? weightValueToIntervalCell(raw, factor.factorValues)
: raw;
});
const selectedCg = getSelectedCustomerGroupCodesFromBilling();
selectedCg.forEach((_, customerIndex) => {
row[`customer_${customerIndex}`] = 0;
});
const key = buildPriceRowFactorKey(row, factorColKeys);
const oldRow = oldByKey.get(key);
if (oldRow) {
kept += 1;
row.calculationFormula = oldRow.calculationFormula || row.calculationFormula;
const priceByCode = extractCustomerPricesByCode(oldRow, oldCodes);
applyCustomerPricesByCode(row, newCodes, priceByCode);
oldByKey.delete(key);
} else {
added += 1;
}
newData.push(row);
});
priceListData.value = newData;
const removed = dropMissing ? oldByKey.size : 0;
if (!dropMissing && oldByKey.size > 0) {
// 保留未匹配旧行(极端兜底,默认不会走)
let nextId = newData.length + 1;
for (const oldRow of oldByKey.values()) {
newData.push({ ...oldRow, id: nextId++ });
}
}
return { newData, kept, added, removed };
};
// 生成价格表
const applySyncedPriceList = (result: {
newData: any[];
kept: number;
added: number;
removed: number;
}) => {
priceListData.value = result.newData;
capturePriceListCustomerCodesSnapshot();
priceListSelectedIds.value = [];
priceDrawerActiveTab.value = 'priceList';
const parts = [`保留 ${result.kept} 行`, `新增 ${result.added} 行`];
if (result.removed > 0) parts.push(`移除 ${result.removed} 行`);
Message.success(`价格表已同步(${parts.join(',')})`);
};
// 同步价格表结构(保留已有价格)
const handleGeneratePriceList = async () => {
await ensureSalesPriceDictLoaded();
generatePriceListData();
// 切换到价格表tab
priceDrawerActiveTab.value = 'priceList';
Message.success('价格表生成成功');
const preview = syncPriceListData({ dropMissing: true });
if (!preview) return;
if (preview.removed > 0 && priceListData.value.length > 0) {
Modal.confirm({
title: '同步价格表',
content: `将新增 ${preview.added} 行、移除 ${preview.removed} 行。被移除行的已填价格会丢失,是否继续?`,
okText: '继续同步',
cancelText: '取消',
onOk: () => {
applySyncedPriceList(preview);
},
});
return;
}
applySyncedPriceList(preview);
};
/** 与文档 /channel/sales-product/price/create 的 factors 一致:serialNumber、factor、factorValues */
@ -2448,6 +2554,8 @@
priceListData.value = importedData;
coercePriceListRowsFactorCells();
capturePriceListCustomerCodesSnapshot();
priceListSelectedIds.value = [];
Message.success('导入成功');
} catch (error) {
Message.error('导入失败,请检查文件格式');
@ -2458,12 +2566,89 @@
input.click();
};
const priceListAllSelected = computed(() => {
const ids = priceListData.value.map((r) => r.id);
return ids.length > 0 && ids.every((id) => priceListSelectedIds.value.includes(id));
});
const toggleSelectAllPriceRows = (checked: boolean | Event) => {
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked;
priceListSelectedIds.value = on ? priceListData.value.map((r) => r.id) : [];
};
const toggleSelectPriceRow = (id: number, checked: boolean | Event) => {
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked;
const set = new Set(priceListSelectedIds.value);
if (on) set.add(id);
else set.delete(id);
priceListSelectedIds.value = [...set];
};
const openBatchFillModal = (onlyEmpty = false) => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先勾选要填价的行');
return;
}
batchFillOnlyEmpty.value = onlyEmpty;
batchFillValue.value = undefined;
batchFillModalVisible.value = true;
};
const handleConfirmBatchFill = () => {
const val = Number(batchFillValue.value);
if (!Number.isFinite(val) || val < 0) {
Message.warning('请输入有效价格(≥0)');
return;
}
const selected = new Set(priceListSelectedIds.value);
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
if (!customerKeys.length) {
Message.warning('当前没有客户组价格列');
return;
}
let touched = 0;
for (const row of priceListData.value) {
if (!selected.has(row.id)) continue;
for (const key of customerKeys) {
const cur = Number((row as any)[key]);
const isEmpty = !Number.isFinite(cur) || cur === 0;
if (batchFillOnlyEmpty.value && !isEmpty) continue;
(row as any)[key] = val;
touched += 1;
}
}
batchFillModalVisible.value = false;
Message.success(touched ? `已填充 ${touched} 个价格格` : '没有需要填充的格子');
};
/** 将上一行客户组价格复制到勾选行(或下一空行) */
const handleCopyPrevRowPrices = () => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先勾选目标行');
return;
}
const selected = new Set(priceListSelectedIds.value);
const customerKeys = priceTableHeaders.value.filter((h) => h.isCustomer).map((h) => h.key);
let copied = 0;
for (let i = 0; i < priceListData.value.length; i++) {
const row = priceListData.value[i];
if (!selected.has(row.id) || i === 0) continue;
const prev = priceListData.value[i - 1];
for (const key of customerKeys) {
(row as any)[key] = Number((prev as any)[key]) || 0;
}
copied += 1;
}
Message.success(copied ? `已将上一行价格复制到 ${copied} 行` : '没有可复制的行');
};
// 取消
const handleCancelPriceDrawer = () => {
priceModalVisible.value = false;
// 重置编辑模式
isEditMode.value = false;
editingPriceRecord.value = null;
priceListSelectedIds.value = [];
// 清理拖拽实例
if (sortableInstance) {
sortableInstance.destroy();
@ -2482,7 +2667,7 @@
// 处理tab切换
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
Message.warning('请配置计费因素和映射值,点击生成价格表');
Message.warning('请配置计费因素和映射值,点击「同步价格表」');
return;
}
if (tabKey === 'priceList') {
@ -2653,9 +2838,11 @@
}
priceListData.value = mapSalesProductDetailsToPriceListRows(sourceRecord?.details || []);
coercePriceListRowsFactorCells();
capturePriceListCustomerCodesSnapshot();
priceListSelectedIds.value = [];
// 重置到基础信息tab
priceDrawerActiveTab.value = 'basic';
// 编辑有明细时直接进入价格表,避免误点「生成」清价
priceDrawerActiveTab.value = priceListData.value.length > 0 ? 'priceList' : 'basic';
priceModalVisible.value = true;
};
@ -3585,7 +3772,7 @@
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
{{ t('channel.salesProducts.drawer.footer.syncPriceList') }}
</a-button>
</a-space>
</div>
@ -3677,8 +3864,33 @@
dictLabelByType('fee_item', basicInfoForm.costItem) ||
t('channel.salesProducts.drawer.section.priceListTitle')
}}
<span class="price-list-hint">{{ t('channel.salesProducts.drawer.priceList.hint') }}</span>
</div>
<div class="price-list-actions">
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(false)"
>
{{ t('channel.salesProducts.drawer.priceList.batchFill') }}
</a-button>
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(true)"
>
{{ t('channel.salesProducts.drawer.priceList.batchFillEmpty') }}
</a-button>
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="handleCopyPrevRowPrices"
>
{{ t('channel.salesProducts.drawer.priceList.copyPrev') }}
</a-button>
<a-button
type="secondary"
size="small"
@ -3706,6 +3918,12 @@
<table class="price-list-table">
<thead>
<tr>
<th style="width: 44px">
<a-checkbox
:model-value="priceListAllSelected"
@change="toggleSelectAllPriceRows"
/>
</th>
<th
v-for="header in priceTableHeaders"
:key="header.key"
@ -3720,6 +3938,12 @@
v-for="(row, index) in priceListData"
:key="row.id"
>
<td class="price-row-check">
<a-checkbox
:model-value="priceListSelectedIds.includes(row.id)"
@change="(v: boolean | Event) => toggleSelectPriceRow(row.id, v)"
/>
</td>
<template v-for="header in priceTableHeaders" :key="header.key">
<td
v-if="header.key.startsWith('factor_')"
@ -3785,7 +4009,7 @@
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
{{ t('channel.salesProducts.drawer.footer.syncPriceList') }}
</a-button>
<a-button
v-if="priceDrawerActiveTab === 'priceList'"
@ -3799,6 +4023,32 @@
</template>
</a-modal>
<!-- 批量填价 -->
<a-modal
v-model:visible="batchFillModalVisible"
:title="batchFillOnlyEmpty
? t('channel.salesProducts.drawer.priceList.batchFillEmpty')
: t('channel.salesProducts.drawer.priceList.batchFill')"
width="420px"
@ok="handleConfirmBatchFill"
@cancel="batchFillModalVisible = false"
>
<div class="batch-fill-form">
<div class="batch-fill-meta">
{{ t('channel.salesProducts.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
</div>
<a-form-item :label="t('channel.salesProducts.drawer.priceList.batchFillValue')" required>
<a-input-number
v-model="batchFillValue"
:min="0"
:precision="2"
style="width: 100%"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
/>
</a-form-item>
</div>
</a-modal>
<!-- 新增产品配置弹窗 -->
<a-modal
v-model:visible="addProductModalVisible"
@ -4730,7 +4980,10 @@
margin-bottom: 16px;
.price-list-title {
display: inline-block;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
@ -4739,10 +4992,19 @@
padding: 4px 12px;
border-radius: 4px;
}
.price-list-hint {
font-size: 12px;
font-weight: 400;
opacity: 0.92;
}
.price-list-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 72%;
}
}
@ -4758,6 +5020,11 @@
width: 100%;
min-width: 1000px;
border-collapse: collapse;
.price-row-check {
text-align: center;
vertical-align: middle;
}
thead {
background-color: #fafafa;
@ -5491,6 +5758,14 @@
}
}
.batch-fill-form {
.batch-fill-meta {
margin-bottom: 12px;
color: var(--color-text-2);
font-size: 13px;
}
}
// 响应式设计
@media (max-width: 768px) {
.sales-product-layout {

View File

@ -549,7 +549,7 @@
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
</a-button>
</a-space>
</div>
@ -641,8 +641,33 @@
dictLabelByType('fee_item', basicInfoForm.costItem) ||
t('channel.serviceChannels.drawer.section.priceListTitle')
}}
<span class="price-list-hint">{{ t('channel.serviceChannels.drawer.priceList.hint') }}</span>
</div>
<div class="price-list-actions">
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(false)"
>
{{ t('channel.serviceChannels.drawer.priceList.batchFill') }}
</a-button>
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="openBatchFillModal(true)"
>
{{ t('channel.serviceChannels.drawer.priceList.batchFillEmpty') }}
</a-button>
<a-button
type="secondary"
size="small"
:disabled="!priceListSelectedIds.length"
@click="handleCopyPrevRowPrices"
>
{{ t('channel.serviceChannels.drawer.priceList.copyPrev') }}
</a-button>
<a-button
type="secondary"
size="small"
@ -671,6 +696,12 @@
<table class="price-list-table">
<thead>
<tr>
<th style="width: 44px">
<a-checkbox
:model-value="priceListAllSelected"
@change="toggleSelectAllPriceRows"
/>
</th>
<th
v-for="header in priceTableHeaders"
:key="header.key"
@ -685,6 +716,12 @@
v-for="(row, index) in priceListData"
:key="row.id"
>
<td class="price-row-check">
<a-checkbox
:model-value="priceListSelectedIds.includes(row.id)"
@change="(v: boolean | Event) => toggleSelectPriceRow(row.id, v)"
/>
</td>
<template v-for="header in priceTableHeaders" :key="header.key">
<td
v-if="header.key.startsWith('factor_')"
@ -749,7 +786,7 @@
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
</a-button>
<a-button
v-if="priceDrawerActiveTab === 'priceList'"
@ -764,6 +801,32 @@
</template>
</a-modal>
<!-- 批量填价 -->
<a-modal
v-model:visible="batchFillModalVisible"
:title="batchFillOnlyEmpty
? t('channel.serviceChannels.drawer.priceList.batchFillEmpty')
: t('channel.serviceChannels.drawer.priceList.batchFill')"
width="420px"
@ok="handleConfirmBatchFill"
@cancel="batchFillModalVisible = false"
>
<div class="batch-fill-form">
<div class="batch-fill-meta">
{{ t('channel.serviceChannels.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
</div>
<a-form-item :label="t('channel.serviceChannels.drawer.priceList.batchFillValue')" required>
<a-input-number
v-model="batchFillValue"
:min="0"
:precision="4"
style="width: 100%"
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
/>
</a-form-item>
</div>
</a-modal>
<!-- 发布弹窗 -->
<ModalPro
:title="t('channel.serviceChannels.publish.title')"
@ -1090,7 +1153,7 @@
v-model:visible="addFactorValueModalVisible"
title="添加因素值"
width="400px"
@cancel="handleCancelAddFactorValue"
@cancel="handleAddFactorValueModalCancel"
@ok="handleConfirmAddFactorValue"
>
<div class="add-factor-value-modal">
@ -2206,6 +2269,10 @@ function buildServiceChannelDetailsPayload(): any[] {
// 价格表数据(详情弹窗内)
const priceListData = ref<any[]>([])
const priceListSelectedIds = ref<number[]>([])
const batchFillModalVisible = ref(false)
const batchFillValue = ref<number | undefined>(undefined)
const batchFillOnlyEmpty = ref(false)
// 价格表数据(用于列表展示)
const priceTableData = ref<any[]>([])
@ -2459,17 +2526,20 @@ const priceTableHeaders = computed(() => {
return [...factorHeaders, formulaHeader, unitPriceHeader]
})
// 生成价格表数据(根据计费因素和因素值,与销售产品卖价一致)
const generatePriceListData = () => {
const newData: any[] = []
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001')
}
// 按计费因素同步价格表:已有组合保留单价,仅补新增行(对齐产品卖价)
const syncPriceListData = (opts?: { dropMissing?: boolean }) => {
const dropMissing = opts?.dropMissing !== false
const configuredFactors = billingFactorsData.value.filter(
(item: BillingFactor) => item.factor && item.factorValues.length > 0
(item: BillingFactor) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
)
if (configuredFactors.length === 0) {
Message.warning('请至少配置一个计费因素及因素值')
return
return null
}
const generateCombinations = (arrays: string[][]): string[][] => {
@ -2491,7 +2561,21 @@ const generatePriceListData = () => {
const factorValueArrays = configuredFactors.map((factor) => factor.factorValues)
const combinations = generateCombinations(factorValueArrays)
const factorColKeys = configuredFactors.map((factor) => {
const colIdx = billingFactorsData.value.findIndex((item: BillingFactor) => item.id === factor.id)
return `factor_${colIdx}`
})
const oldByKey = new Map<string, any>()
for (const row of priceListData.value) {
const key = buildPriceRowFactorKey(row, factorColKeys)
if (!key || key.split('\u0001').every((p) => !p)) continue
if (!oldByKey.has(key)) oldByKey.set(key, row)
}
const newData: any[] = []
let kept = 0
let added = 0
combinations.forEach((combination, index) => {
const row: any = {
id: index + 1,
@ -2507,19 +2591,133 @@ const generatePriceListData = () => {
: raw
})
const key = buildPriceRowFactorKey(row, factorColKeys)
const oldRow = oldByKey.get(key)
if (oldRow) {
kept += 1
row.calculationFormula = oldRow.calculationFormula || row.calculationFormula
const n = Number(oldRow.calculatedValue)
row.calculatedValue = Number.isFinite(n) ? n : 0
oldByKey.delete(key)
} else {
added += 1
}
newData.push(row)
})
priceListData.value = newData
const removed = dropMissing ? oldByKey.size : 0
if (!dropMissing && oldByKey.size > 0) {
let nextId = newData.length + 1
for (const oldRow of oldByKey.values()) {
newData.push({ ...oldRow, id: nextId++ })
}
}
return { newData, kept, added, removed }
}
// 生成价格表
const applySyncedPriceList = (result: {
newData: any[]
kept: number
added: number
removed: number
}) => {
priceListData.value = result.newData
priceListSelectedIds.value = []
priceDrawerActiveTab.value = 'priceList'
const parts = [`保留 ${result.kept} 行`, `新增 ${result.added} 行`]
if (result.removed > 0) parts.push(`移除 ${result.removed} 行`)
Message.success(`价格表已同步(${parts.join(',')})`)
}
// 同步价格表结构(保留已有单价)
const handleGeneratePriceList = async () => {
await ensureChannelPriceDictLoaded()
await loadBillingFactorSelectOptions()
generatePriceListData()
priceDrawerActiveTab.value = 'priceList'
Message.success('价格表生成成功')
const preview = syncPriceListData({ dropMissing: true })
if (!preview) return
if (preview.removed > 0 && priceListData.value.length > 0) {
Modal.confirm({
title: '同步价格表',
content: `将新增 ${preview.added} 行、移除 ${preview.removed} 行。被移除行的已填价格会丢失,是否继续?`,
okText: '继续同步',
cancelText: '取消',
onOk: () => {
applySyncedPriceList(preview)
},
})
return
}
applySyncedPriceList(preview)
}
const priceListAllSelected = computed(() => {
const ids = priceListData.value.map((r) => r.id)
return ids.length > 0 && ids.every((id) => priceListSelectedIds.value.includes(id))
})
const toggleSelectAllPriceRows = (checked: boolean | Event) => {
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked
priceListSelectedIds.value = on ? priceListData.value.map((r) => r.id) : []
}
const toggleSelectPriceRow = (id: number, checked: boolean | Event) => {
const on = typeof checked === 'boolean' ? checked : !!(checked as any)?.target?.checked
const set = new Set(priceListSelectedIds.value)
if (on) set.add(id)
else set.delete(id)
priceListSelectedIds.value = [...set]
}
const openBatchFillModal = (onlyEmpty = false) => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先勾选要填价的行')
return
}
batchFillOnlyEmpty.value = onlyEmpty
batchFillValue.value = undefined
batchFillModalVisible.value = true
}
const handleConfirmBatchFill = () => {
const val = Number(batchFillValue.value)
if (!Number.isFinite(val) || val < 0) {
Message.warning('请输入有效价格(≥0)')
return
}
const selected = new Set(priceListSelectedIds.value)
let touched = 0
for (const row of priceListData.value) {
if (!selected.has(row.id)) continue
const cur = Number(row.calculatedValue)
const isEmpty = !Number.isFinite(cur) || cur === 0
if (batchFillOnlyEmpty.value && !isEmpty) continue
row.calculatedValue = val
touched += 1
}
batchFillModalVisible.value = false
Message.success(touched ? `已填充 ${touched} 行单价` : '没有需要填充的行')
}
/** 将上一行单价复制到勾选行 */
const handleCopyPrevRowPrices = () => {
if (!priceListSelectedIds.value.length) {
Message.warning('请先勾选目标行')
return
}
const selected = new Set(priceListSelectedIds.value)
let copied = 0
for (let i = 0; i < priceListData.value.length; i++) {
const row = priceListData.value[i]
if (!selected.has(row.id) || i === 0) continue
const prev = priceListData.value[i - 1]
row.calculatedValue = Number(prev.calculatedValue) || 0
copied += 1
}
Message.success(copied ? `已将上一行单价复制到 ${copied} 行` : '没有可复制的行')
}
const savingPriceDrawer = ref(false)
@ -2593,7 +2791,7 @@ const handleSavePriceList = async () => {
return
}
if (!priceListData.value.length) {
Message.warning('请先在「基础信息」中生成价格表后再保存')
Message.warning('请先在「基础信息」中同步价格表后再保存')
return
}
savingPriceDrawer.value = true
@ -2819,7 +3017,7 @@ const canSwitchToPriceList = computed(() => {
// 处理tab切换
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
Message.warning('请配置计费因素和映射值,点击生成价格表')
Message.warning('请配置计费因素和映射值,点击「同步价格表」')
return
}
if (tabKey === 'priceList') {
@ -2827,6 +3025,11 @@ const handlePriceDrawerTabSwitch = async (tabKey: string) => {
await loadBillingFactorSelectOptions()
}
priceDrawerActiveTab.value = tabKey
if (tabKey === 'basic' && priceModalVisible.value) {
nextTick(() => {
initSortable()
})
}
}
@ -3254,11 +3457,15 @@ const handleEditPrice = async (record: any) => {
}
priceListData.value = mapDetailsToPriceListRows(sourceRecord?.details || [])
coercePriceListRowsFactorCells()
priceDrawerActiveTab.value = 'basic'
priceListSelectedIds.value = []
// 编辑有明细时直接进入价格表,避免误点「同步」清价
priceDrawerActiveTab.value = priceListData.value.length > 0 ? 'priceList' : 'basic'
priceModalVisible.value = true
nextTick(() => {
initSortable()
})
if (priceDrawerActiveTab.value === 'basic') {
nextTick(() => {
initSortable()
})
}
}
// 删除价格 - 使用弹窗确认
@ -3378,6 +3585,7 @@ const handleCancelPriceDrawer = () => {
// 重置编辑模式
isEditMode.value = false
editingPriceRecord.value = null
priceListSelectedIds.value = []
// 清理拖拽实例
if (sortableInstance) {
sortableInstance.destroy()
@ -3457,34 +3665,60 @@ const handleAddFactorValue = async (row: any) => {
await ensureChannelPriceDictLoaded()
await loadBillingFactorSelectOptions()
currentFactorRow.value = row
addFactorValueSelected.value = []
const factor = row.factor as string
const opts = factorValuesOptionsByFactor.value[factor] || []
const allowed = new Set(opts.map((o) => String(o.value)))
const canonical = toCanonicalFactorValues(factor, row.factorValues || [])
addFactorValueSelected.value = canonical
.map((v) => String(v).trim())
.filter((v) => v && allowed.has(v))
addFactorValueModalVisible.value = true
}
const handleConfirmAddFactorValue = () => {
function resetAddFactorValueModal() {
addFactorValueModalVisible.value = false
addFactorValueSelected.value = []
currentFactorRow.value = null
}
/** 将弹窗内勾选的因素值写入当前行;forbidEmpty 为 true 且无勾选时提示并返回 false */
function commitAddFactorSelections(opts: { forbidEmpty: boolean }): boolean {
const selectedValues = addFactorValueSelected.value || []
if (selectedValues.length === 0) {
Message.warning('请至少选择一个因素值')
return
if (opts.forbidEmpty) {
Message.warning('请至少选择一个因素值')
}
return false
}
if (!currentFactorRow.value) return
if (!currentFactorRow.value) return false
const existing = new Set(currentFactorRow.value.factorValues || [])
const toAdd = selectedValues.filter((value) => !existing.has(value))
if (toAdd.length === 0) {
Message.warning('所选因素值均已存在')
return
return false
}
currentFactorRow.value.factorValues.push(...toAdd)
addFactorValueModalVisible.value = false
addFactorValueSelected.value = []
currentFactorRow.value = null
Message.success('添加成功')
return true
}
const handleCancelAddFactorValue = () => {
addFactorValueModalVisible.value = false
addFactorValueSelected.value = []
currentFactorRow.value = null
const handleConfirmAddFactorValue = () => {
if (!commitAddFactorSelections({ forbidEmpty: true })) return
resetAddFactorValueModal()
}
/** 点击遮罩关闭时:按当前勾选提交(无勾选则仅关闭);点取消/X/ESC 仍丢弃 */
function handleAddFactorValueModalCancel(e?: Event) {
const el = e && 'target' in e ? ((e as MouseEvent).target as HTMLElement | null) : null
const fromMask = !!el?.classList?.contains('arco-modal-wrapper')
if (fromMask) {
commitAddFactorSelections({ forbidEmpty: false })
resetAddFactorValueModal()
return
}
resetAddFactorValueModal()
}
const handleSelectAllFactorValues = () => {
@ -4279,14 +4513,27 @@ const handleConfirmPublish = async () => {
margin-bottom: 16px;
.price-list-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.price-list-hint {
font-size: 12px;
font-weight: 400;
color: #86909c;
}
.price-list-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 72%;
}
}
@ -4302,6 +4549,11 @@ const handleConfirmPublish = async () => {
width: 100%;
min-width: 1000px;
border-collapse: collapse;
.price-row-check {
text-align: center;
vertical-align: middle;
}
thead {
background-color: #fafafa;
@ -4837,4 +5089,12 @@ const handleConfirmPublish = async () => {
margin-bottom: 8px;
}
}
.batch-fill-form {
.batch-fill-meta {
margin-bottom: 12px;
color: #4e5969;
font-size: 13px;
}
}
</style>

View File

@ -88,7 +88,16 @@
{{ t('h5.orders.askAdvisor') }}
</button>
<button
v-if="isPendingPay"
v-if="isDraft"
type="button"
class="h5-detail__btn is-primary"
:disabled="predeclaring"
@click="handlePreDeclare"
>
{{ predeclaring ? t('h5.orders.detail.predeclaring') : t('h5.orders.detail.predeclare') }}
</button>
<button
v-else-if="isPendingPay"
type="button"
class="h5-detail__btn is-primary"
@click="openPay"
@ -114,9 +123,9 @@
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Modal } from '@arco-design/web-vue';
import { Message, Modal } from '@arco-design/web-vue';
import { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
import { getOrderDetail } from '@/api/order';
import { getOrderDetail, postOrderPreDeclare } from '@/api/order';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { h5Navigate } from '@/utils/device';
import {
@ -178,12 +187,16 @@ const waybillNo = computed(
);
const statusCode = computed(() => resolveOrderStatusCode(detail.value));
const isPendingPay = computed(() => statusCode.value === 'pendingPayment');
const isDraft = computed(() => statusCode.value === 'draft');
const predeclaring = ref(false);
const overviewRows = computed(() => {
const d = detail.value || {};
const r = d.receiver || d.Receiver || {};
const meta = getOrderStatusMeta(statusCode.value);
return [
const failReason =
pick(d, 'predeclareFailReason', 'PredeclareFailReason') || '';
const rows = [
{ label: t('h5.orders.detail.waybillNo'), value: waybillNo.value || '-' },
{
label: t('h5.orders.detail.customerOrderNo'),
@ -197,6 +210,14 @@ const overviewRows = computed(() => {
label: t('h5.orders.detail.status'),
value: meta ? t(meta.labelKey) : statusCode.value || '-',
},
];
if (failReason) {
rows.push({
label: t('h5.orders.detail.predeclareFailReason'),
value: String(failReason),
});
}
rows.push(
{
label: t('h5.orders.detail.channel'),
value: pick(d, 'channelName', 'ChannelName', 'productName', 'ProductName') || '-',
@ -219,7 +240,8 @@ const overviewRows = computed(() => {
label: t('h5.orders.detail.weight'),
value: pick(d, 'chargeableWeight', 'ChargeableWeight') || '-',
},
];
);
return rows;
});
const feeList = computed(() => {
@ -285,6 +307,22 @@ function openPay() {
payVisible.value = true;
}
async function handlePreDeclare() {
if (!props.orderId || predeclaring.value) return;
predeclaring.value = true;
try {
await postOrderPreDeclare({ id: props.orderId });
Message.success(t('h5.orders.detail.predeclareOk'));
await loadDetail();
emit('paid');
} catch (e: any) {
Message.error(e?.message || t('h5.orders.detail.predeclareFailed'));
await loadDetail();
} finally {
predeclaring.value = false;
}
}
function onPaySuccess() {
void loadDetail();
emit('paid');

File diff suppressed because it is too large Load Diff

View File

@ -114,6 +114,11 @@ export default {
'h5.orders.detail.noTrack': 'No tracking yet',
'h5.orders.detail.noFee': 'No fee details',
'h5.orders.detail.close': 'Close',
'h5.orders.detail.predeclareFailReason': 'Pre-declare failure',
'h5.orders.detail.predeclare': 'Pre-declare again',
'h5.orders.detail.predeclaring': 'Pre-declaring…',
'h5.orders.detail.predeclareOk': 'Pre-declared',
'h5.orders.detail.predeclareFailed': 'Pre-declare failed',
'h5.me.guestTitle': 'Welcome to Customer Center',
'h5.me.guestDesc':
@ -226,20 +231,46 @@ export default {
'h5.placeOrder.default': 'Default',
'h5.placeOrder.receiverName': 'Recipient',
'h5.placeOrder.receiverPhone': 'Phone',
'h5.placeOrder.company': 'Company',
'h5.placeOrder.telePhone': 'Tel',
'h5.placeOrder.email': 'Email',
'h5.placeOrder.streetNumber': 'House No.',
'h5.placeOrder.country': 'Country',
'h5.placeOrder.address': 'Address',
'h5.placeOrder.address': 'Address 1',
'h5.placeOrder.address2': 'Address 2',
'h5.placeOrder.address3': 'Address 3',
'h5.placeOrder.city': 'City',
'h5.placeOrder.province': 'State / Province',
'h5.placeOrder.zip': 'Postal code',
'h5.placeOrder.taxNo': 'Tax No.',
'h5.placeOrder.certificateNo': 'ID No.',
'h5.placeOrder.eori': 'EORI',
'h5.placeOrder.vat': 'VAT',
'h5.placeOrder.currency': 'Declared currency',
'h5.placeOrder.enName': 'Item name (EN)',
'h5.placeOrder.cnName': 'Item name (CN)',
'h5.placeOrder.sku': 'SKU',
'h5.placeOrder.unit': 'Unit',
'h5.placeOrder.unitPrice': 'Unit price',
'h5.placeOrder.unitWeight': 'Unit weight',
'h5.placeOrder.qty': 'Qty',
'h5.placeOrder.amount': 'Amount',
'h5.placeOrder.itemWeight': 'Weight',
'h5.placeOrder.imHsCode': 'Import HS',
'h5.placeOrder.exHsCode': 'Export HS',
'h5.placeOrder.brand': 'Brand',
'h5.placeOrder.model': 'Model',
'h5.placeOrder.material': 'Material',
'h5.placeOrder.enMaterial': 'Material (EN)',
'h5.placeOrder.purpose': 'Purpose',
'h5.placeOrder.originCountry': 'Origin',
'h5.placeOrder.category': 'Category',
'h5.placeOrder.specifications': 'Specs',
'h5.placeOrder.salesLink': 'Sales link',
'h5.placeOrder.comment': 'Comment',
'h5.placeOrder.itemTitle': 'Item {n}',
'h5.placeOrder.showMore': 'More fields',
'h5.placeOrder.hideMore': 'Hide more',
'h5.placeOrder.addItem': 'Add item',
'h5.placeOrder.removeItem': 'Remove',
'h5.placeOrder.err.itemsEmpty': 'Add at least one declaration item',
@ -253,6 +284,34 @@ export default {
'h5.placeOrder.goOrders': 'Go to Orders',
'h5.placeOrder.stay': 'Stay',
'h5.placeOrder.failed': 'Failed to create order',
'h5.placeOrder.failTitle': 'Order was not placed',
'h5.placeOrder.failTitleDraft': 'Pre-declaration failed (draft saved)',
'h5.placeOrder.failWhat': 'What happened',
'h5.placeOrder.failHow': 'What to change',
'h5.placeOrder.failRaw': 'Details',
'h5.placeOrder.failDraftNote':
'Saved as draft. Open the order, fix details, then pre-declare again. Do not resubmit here.',
'h5.placeOrder.failNotSavedNote':
'The order was not saved. Continue editing and retry, or save it as a draft.',
'h5.placeOrder.failContinue': 'Continue editing',
'h5.placeOrder.failSaveDraft': 'Save as draft',
'h5.placeOrder.failSaveDraftOk': 'Saved as draft',
'h5.placeOrder.failSaveDraftFailed': 'Failed to save draft',
'h5.placeOrder.failSaveDraftGo': 'Open draft',
'h5.placeOrder.failSaveDraftStay': 'Stay',
'h5.placeOrder.failGoDraft': 'Open draft & pre-declare',
'h5.placeOrder.failAbandon': 'Discard',
'h5.placeOrder.failCancelDraft': 'Cancel (delete draft)',
'h5.placeOrder.failAbandonTitle': 'Discard this form?',
'h5.placeOrder.failAbandonContent': 'The current form will be cleared.',
'h5.placeOrder.failAbandonOk': 'Discard',
'h5.placeOrder.failAbandonDone': 'Cleared',
'h5.placeOrder.failCancelDraftTitle': 'Cancel this submission?',
'h5.placeOrder.failCancelDraftContent': 'The draft just saved will be deleted.',
'h5.placeOrder.failCancelDraftOk': 'Delete draft',
'h5.placeOrder.failKeep': 'Keep editing',
'h5.placeOrder.failCancelDraftDone': 'Draft deleted',
'h5.placeOrder.failCancelDraftFailed': 'Failed to delete',
'h5.placeOrder.optionsFailed': 'Failed to load options',
'h5.placeOrder.err.clientNo': 'Enter customer order No.',
'h5.placeOrder.err.product': 'Select shipping method',

View File

@ -114,6 +114,11 @@ export default {
'h5.orders.detail.noTrack': '暂无轨迹',
'h5.orders.detail.noFee': '暂无费用明细',
'h5.orders.detail.close': '关闭',
'h5.orders.detail.predeclareFailReason': '预报失败原因',
'h5.orders.detail.predeclare': '重新预报',
'h5.orders.detail.predeclaring': '预报中…',
'h5.orders.detail.predeclareOk': '预报成功',
'h5.orders.detail.predeclareFailed': '预报失败',
'h5.me.guestTitle': '欢迎使用客户中心',
'h5.me.guestDesc': '微信内长按二维码识别即可登录;新用户可先注册或绑定',
@ -225,20 +230,46 @@ export default {
'h5.placeOrder.default': '默认',
'h5.placeOrder.receiverName': '收件人',
'h5.placeOrder.receiverPhone': '手机号',
'h5.placeOrder.company': '公司',
'h5.placeOrder.telePhone': '电话',
'h5.placeOrder.email': '邮箱',
'h5.placeOrder.streetNumber': '门牌号',
'h5.placeOrder.country': '目的国家',
'h5.placeOrder.address': '地址',
'h5.placeOrder.address': '地址1',
'h5.placeOrder.address2': '地址2',
'h5.placeOrder.address3': '地址3',
'h5.placeOrder.city': '城市',
'h5.placeOrder.province': '州/省',
'h5.placeOrder.zip': '邮编',
'h5.placeOrder.taxNo': '税号',
'h5.placeOrder.certificateNo': '证件号码',
'h5.placeOrder.eori': 'EORI',
'h5.placeOrder.vat': 'VAT',
'h5.placeOrder.currency': '申报币种',
'h5.placeOrder.enName': '品名(英)',
'h5.placeOrder.cnName': '品名(中)',
'h5.placeOrder.sku': 'SKU',
'h5.placeOrder.unit': '单位',
'h5.placeOrder.unitPrice': '单价',
'h5.placeOrder.unitWeight': '单重',
'h5.placeOrder.qty': '数量',
'h5.placeOrder.amount': '总价',
'h5.placeOrder.itemWeight': '总重',
'h5.placeOrder.imHsCode': '进口HS',
'h5.placeOrder.exHsCode': '出口HS',
'h5.placeOrder.brand': '品牌',
'h5.placeOrder.model': '型号',
'h5.placeOrder.material': '材质',
'h5.placeOrder.enMaterial': '英文材质',
'h5.placeOrder.purpose': '用途',
'h5.placeOrder.originCountry': '原产国',
'h5.placeOrder.category': '类别',
'h5.placeOrder.specifications': '规格',
'h5.placeOrder.salesLink': '销售链接',
'h5.placeOrder.comment': '备注',
'h5.placeOrder.itemTitle': '申报品 {n}',
'h5.placeOrder.showMore': '更多信息',
'h5.placeOrder.hideMore': '收起更多',
'h5.placeOrder.addItem': '添加申报品',
'h5.placeOrder.removeItem': '删除',
'h5.placeOrder.err.itemsEmpty': '请至少添加一条申报品',
@ -252,6 +283,32 @@ export default {
'h5.placeOrder.goOrders': '去查订单',
'h5.placeOrder.stay': '继续下单',
'h5.placeOrder.failed': '下单失败',
'h5.placeOrder.failTitle': '下单未成功',
'h5.placeOrder.failTitleDraft': '预报未完成(草稿已保存)',
'h5.placeOrder.failWhat': '发生了什么',
'h5.placeOrder.failHow': '建议修改',
'h5.placeOrder.failRaw': '详细原因',
'h5.placeOrder.failDraftNote': '订单已存为草稿。请打开订单详情修改后重新预报,勿在此页再次提交。',
'h5.placeOrder.failNotSavedNote': '订单尚未落库。可继续修改后重试,或先保存为草稿。',
'h5.placeOrder.failContinue': '继续修改',
'h5.placeOrder.failSaveDraft': '保存为草稿',
'h5.placeOrder.failSaveDraftOk': '已保存为草稿',
'h5.placeOrder.failSaveDraftFailed': '保存草稿失败',
'h5.placeOrder.failSaveDraftGo': '去看草稿',
'h5.placeOrder.failSaveDraftStay': '继续留在本页',
'h5.placeOrder.failGoDraft': '去看草稿并预报',
'h5.placeOrder.failAbandon': '放弃本次',
'h5.placeOrder.failCancelDraft': '取消提交(删草稿)',
'h5.placeOrder.failAbandonTitle': '放弃本次填写?',
'h5.placeOrder.failAbandonContent': '将清空当前表单。',
'h5.placeOrder.failAbandonOk': '确认放弃',
'h5.placeOrder.failAbandonDone': '已清空',
'h5.placeOrder.failCancelDraftTitle': '取消本次提交?',
'h5.placeOrder.failCancelDraftContent': '将删除刚保存的草稿。',
'h5.placeOrder.failCancelDraftOk': '确认删除',
'h5.placeOrder.failKeep': '再想想',
'h5.placeOrder.failCancelDraftDone': '草稿已删除',
'h5.placeOrder.failCancelDraftFailed': '删除失败',
'h5.placeOrder.optionsFailed': '加载选项失败',
'h5.placeOrder.err.clientNo': '请填写客户单号',
'h5.placeOrder.err.product': '请选择运输方式',

View File

@ -509,7 +509,26 @@ watch(activeGroup, () => {
void reload();
});
void reload();
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;
goDetail({ id });
const nextQuery = { ...route.query } as Record<string, any>;
delete nextQuery.orderId;
await router.replace({ query: nextQuery });
}
watch(
() => route.query.orderId,
(orderId) => {
if (!orderId) return;
void tryOpenOrderFromQuery();
},
);
void reload().then(() => tryOpenOrderFromQuery());
</script>
<style lang="less" scoped>

View File

@ -0,0 +1,342 @@
<template>
<div class="order-attachments-tab">
<a-spin :loading="loading" class="attachments-spin">
<div
v-for="section in visibleSections"
:key="section.type"
class="attach-section"
>
<div class="attach-section-header">
<div class="attach-section-title-wrap">
<span class="attach-section-title">{{ $t(section.labelKey) }}</span>
<span class="attach-section-count">
{{ section.files.length }}/{{ section.maxCount }}
</span>
</div>
<a-upload
v-if="canUpload"
:accept="section.accept"
:multiple="section.maxCount > 1"
:show-file-list="false"
:disabled="uploadingType === section.type || section.files.length >= section.maxCount"
:custom-request="(opt) => handleUpload(section.type, opt)"
>
<a-button
type="outline"
size="small"
:loading="uploadingType === section.type"
:disabled="section.files.length >= section.maxCount"
>
{{ $t('order.myOrders.drawer.attachments.upload') }}
</a-button>
</a-upload>
</div>
<div v-if="section.files.length" class="attach-file-list">
<div
v-for="file in section.files"
:key="file.id"
class="attach-file-item"
>
<a
class="attach-file-name"
:href="resolveUrl(file.fileUrl)"
target="_blank"
rel="noopener noreferrer"
>
{{ file.fileName || file.fileUrl }}
</a>
<div class="attach-file-meta">
<span>{{ formatTime(file.createTime) }}</span>
<span v-if="file.source">{{ $t(`order.myOrders.drawer.attachments.source.${file.source}`, file.source) }}</span>
</div>
<div class="attach-file-actions">
<a-button
type="text"
size="mini"
@click="openFile(file.fileUrl)"
>
{{ $t('order.myOrders.drawer.attachments.preview') }}
</a-button>
<a-button
v-if="canUpload"
type="text"
size="mini"
status="danger"
:loading="deletingId === file.id"
@click="handleDelete(file.id)"
>
{{ $t('order.myOrders.drawer.attachments.delete') }}
</a-button>
</div>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.attachments.empty')"
:image-style="{ height: '48px' }"
class="attach-empty"
/>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import {
deleteOrderFile,
getOrderFiles,
uploadOrderFiles,
type OrderFileItem,
} from '@/api/order';
import { resolveOmsAssetUrl } from '@/api/oms/common';
const FILE_TYPE_DEFS = [
{
type: 'tradeProof',
labelKey: 'order.myOrders.drawer.attachments.types.tradeProof',
maxCount: 20,
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
},
{
type: 'systemLabel',
labelKey: 'order.myOrders.drawer.attachments.types.systemLabel',
maxCount: 1,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'downstreamLabel',
labelKey: 'order.myOrders.drawer.attachments.types.downstreamLabel',
maxCount: 20,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'lastMileLabel',
labelKey: 'order.myOrders.drawer.attachments.types.lastMileLabel',
maxCount: 20,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'infraredImage',
labelKey: 'order.myOrders.drawer.attachments.types.infraredImage',
maxCount: 20,
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
},
] as const;
const props = withDefaults(
defineProps<{
orderId?: number | null;
active?: boolean;
/** 客户门户:只读系统面单 */
readOnly?: boolean;
customerView?: boolean;
}>(),
{
orderId: null,
active: false,
readOnly: false,
customerView: false,
}
);
const { t } = useI18n();
const loading = ref(false);
const uploadingType = ref<string | null>(null);
const deletingId = ref<number | null>(null);
const groups = ref<Record<string, OrderFileItem[]>>({});
const canUpload = computed(() => !props.readOnly && !props.customerView);
const visibleSections = computed(() => {
const defs = props.customerView
? FILE_TYPE_DEFS.filter((d) => d.type === 'systemLabel')
: FILE_TYPE_DEFS;
return defs.map((def) => ({
...def,
files: groups.value[def.type] ?? [],
}));
});
function resolveUrl(path?: string) {
return resolveOmsAssetUrl(path);
}
function openFile(path?: string) {
const url = resolveUrl(path);
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
function formatTime(value?: string) {
if (!value) return '';
return String(value).replace('T', ' ').slice(0, 19);
}
async function loadFiles() {
if (!props.orderId) {
groups.value = {};
return;
}
loading.value = true;
try {
const data = await getOrderFiles(props.orderId);
groups.value = data.groups ?? {};
} catch (e: any) {
groups.value = {};
Message.error(e?.message || t('order.myOrders.drawer.attachments.loadFailed'));
} finally {
loading.value = false;
}
}
async function handleUpload(fileType: string, options: any) {
const file = options?.fileItem?.file ?? options?.file;
if (!file || !props.orderId) {
options?.onError?.(new Error('no file'));
return;
}
uploadingType.value = fileType;
try {
await uploadOrderFiles(props.orderId, fileType, [file as File]);
options?.onSuccess?.();
Message.success(t('order.myOrders.drawer.attachments.uploadSuccess'));
await loadFiles();
} catch (e: any) {
options?.onError?.(e);
Message.error(e?.message || t('order.myOrders.drawer.attachments.uploadFailed'));
} finally {
uploadingType.value = null;
}
}
function handleDelete(id: number) {
Modal.confirm({
title: t('order.myOrders.drawer.attachments.deleteConfirmTitle'),
content: t('order.myOrders.drawer.attachments.deleteConfirm'),
onOk: async () => {
deletingId.value = id;
try {
await deleteOrderFile(id);
Message.success(t('order.myOrders.drawer.attachments.deleteSuccess'));
await loadFiles();
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.drawer.attachments.deleteFailed'));
} finally {
deletingId.value = null;
}
},
});
}
watch(
() => [props.active, props.orderId] as const,
([active, orderId]) => {
if (active && orderId) {
void loadFiles();
}
},
{ immediate: true }
);
</script>
<style scoped lang="less">
.order-attachments-tab {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 200px;
}
.attachments-spin {
width: 100%;
}
.attach-section {
background: #fff;
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 12px 16px;
}
.attach-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.attach-section-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
}
.attach-section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.attach-section-count {
font-size: 12px;
color: var(--color-text-3);
}
.attach-file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attach-file-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px 12px;
align-items: center;
padding: 8px 10px;
background: #f7f8fa;
border-radius: 4px;
}
.attach-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-primary-color);
font-size: 13px;
}
.attach-file-meta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: var(--color-text-3);
text-align: right;
}
.attach-file-actions {
display: flex;
align-items: center;
gap: 4px;
}
.attach-empty {
margin: 8px 0;
}
@media (max-width: 720px) {
.attach-file-item {
grid-template-columns: 1fr;
}
.attach-file-meta {
text-align: left;
}
}
</style>

View File

@ -79,6 +79,30 @@
</div>
</div>
<div v-if="predeclareFailGuide" class="predeclare-fail-guide">
<div class="predeclare-fail-guide__title">
{{ $t('order.myOrders.drawer.failGuideTitle') }}
</div>
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
<p
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
class="predeclare-fail-guide__raw"
>
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }}
</p>
<ul class="predeclare-fail-guide__tips">
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li>
</ul>
<div class="predeclare-fail-guide__actions">
<a-button size="small" type="outline" @click="handleAskAdvisor">
{{ $t('order.myOrders.drawer.failGuideAskSupport') }}
</a-button>
<a-button size="small" type="primary" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.failGuideEdit') }}
</a-button>
</div>
</div>
<div class="basic-view-switch">
<a-radio-group
v-model="basicViewMode"
@ -428,6 +452,16 @@
</a-spin>
</div>
<!-- 附件 -->
<div v-if="activeTab === 'attachments'" class="tab-panel attachments-info">
<OrderAttachmentsTab
:order-id="orderId"
:active="activeTab === 'attachments'"
:customer-view="isCustomerPortal"
:read-only="isCustomerPortal"
/>
</div>
<!-- 绑定包裹/库存 -->
<div v-if="activeTab === 'stockBind'" class="tab-panel stock-bind-info">
<OrderStockBindTab
@ -465,6 +499,8 @@ import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { openAskAdvisor } from '@/composables/useAskAdvisor';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
import AddOrderFeeModal from './AddOrderFeeModal.vue';
import OrderStockBindTab from './OrderStockBindTab.vue';
@ -547,6 +583,14 @@ const currentOrderStatusMeta = computed(() => {
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 {

View File

@ -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 `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
const guide = buildFailGuideFromReason(String(reason));
const display = guide.reason || String(reason);
const escaped = escapeHtml(display);
const title = escapeHtml(guide.rawReason || String(reason));
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
}
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<string, any>;
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();
},
);
</script>
<template>

View File

@ -12,6 +12,7 @@
import {
getOrderTemplateList,
postOrderParcelCreate,
postOrderDelete,
downloadOrderTemplate,
deleteOrderTemplateCustom,
postOrderUpload,
@ -29,6 +30,11 @@
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate';
import {
buildParcelCreateFailGuide,
type ParcelCreateFailGuide,
} from '@/utils/parcelCreateFailGuide';
import { openAskAdvisor } from '@/composables/useAskAdvisor';
import CompleteContactModal from '@/components/CompleteContactModal/index.vue';
import type { CustomerProfileCompleteness } from '@/api/customer';
@ -2149,6 +2155,229 @@
});
}
const createFailVisible = ref(false);
const createFailCancelling = ref(false);
const createFailSavingDraft = ref(false);
const createFailInfo = ref<ParcelCreateFailGuide | null>(null);
function emptyDeclarationItem() {
return {
cnName: '',
enName: '',
sku: '',
unit: '',
unitPrice: '',
unitWeight: '',
quantity: '',
amount: '',
weight: '',
imHsCode: '',
exHsCode: '',
skuImage: '',
category: '',
brand: '',
brandType: '',
isMagnetic: false,
distribution: '',
material: '',
enMaterial: '',
purpose: '',
attributes: '',
originCountry: '',
salesLink: '',
model: '',
specifications: '',
comment: '',
};
}
function resetSingleForm() {
Object.assign(singleForm, {
clientNo: '',
productCode: '',
cargoAttribute: '',
exportCustomsMode: '',
warehouseId: '',
length: '10',
width: '10',
height: '10',
actualWeight: '',
isCod: false,
codCurrency: '',
codSum: '',
declaredCurrency: resolveDefaultDeclaredCurrency(),
quantity: '1',
});
Object.assign(singleForm.receiver, {
recipientsName: '',
companyName: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
streetNumber: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
zipCode: '',
taxType: '',
taxNo: '',
certificateType: '',
certificateNo: '',
eori: '',
vat: '',
});
Object.assign(singleForm.sender, emptySenderForm());
singleForm.items = [emptyDeclarationItem()];
selectedSenderAddressId.value = undefined;
singleShowFieldErrors.value = false;
singleAgreementChecked.value = false;
priceCheckHandoff.value = null;
}
function openCreateFailModal(error: unknown) {
createFailInfo.value = buildParcelCreateFailGuide(error);
createFailVisible.value = true;
}
function closeCreateFailModal() {
createFailVisible.value = false;
}
function handleCreateFailContinueEdit() {
const info = createFailInfo.value;
closeCreateFailModal();
if (!info) return;
if (info.draftSaved && info.orderId) {
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: { status: 'draft', orderId: String(info.orderId), edit: '1' },
});
return;
}
singleShowFieldErrors.value = true;
focusSingleSection(info.section);
}
async function handleCreateFailSaveAsDraft() {
const info = createFailInfo.value;
if (!info || info.draftSaved) return;
createFailSavingDraft.value = true;
try {
const created = await postOrderParcelCreate({
...buildSinglePayload(),
saveAsDraft: true,
});
const orderId = Number(created?.orderId ?? created?.OrderId ?? created?.id ?? created?.Id ?? 0) || undefined;
closeCreateFailModal();
priceCheckHandoff.value = null;
singleShowFieldErrors.value = false;
Modal.confirm({
title: t('order.placeOrder.single.failSaveAsDraftOk'),
content: t('order.placeOrder.single.failDraftSavedNote'),
okText: t('order.placeOrder.single.failSaveAsDraftGo'),
cancelText: t('order.placeOrder.single.failSaveAsDraftStay'),
onOk: () => {
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: {
status: 'draft',
...(orderId ? { orderId: String(orderId), edit: '1' } : {}),
},
});
},
onCancel: () => {
resetSingleForm();
},
});
} catch (e: any) {
Message.error(e?.message || t('order.placeOrder.single.failSaveAsDraftFailed'));
} finally {
createFailSavingDraft.value = false;
}
}
function handleCreateFailAskSupport() {
const info = createFailInfo.value;
openAskAdvisor(router, {
orderId: info?.orderId ? String(info.orderId) : undefined,
waybillNo: info?.serviceNo,
from: rbacUserStore.isCustomerPortal() ? 'myOrders' : 'waybillManage',
orderStatus: info?.draftSaved ? 'draft' : undefined,
});
}
async function handleCreateFailCopyServiceNo() {
const no = createFailInfo.value?.serviceNo;
if (!no) return;
try {
await navigator.clipboard.writeText(no);
Message.success(t('order.placeOrder.single.failCopied'));
} catch {
Message.warning(t('order.placeOrder.single.failCopyFailed'));
}
}
function handleCreateFailGoDrafts() {
const info = createFailInfo.value;
closeCreateFailModal();
router.push({
path: rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
query: {
status: 'draft',
...(info?.orderId ? { orderId: String(info.orderId) } : {}),
},
});
}
function handleCreateFailCancelSubmit() {
const info = createFailInfo.value;
if (!info) {
closeCreateFailModal();
return;
}
if (info.draftSaved && info.orderId) {
Modal.confirm({
title: t('order.placeOrder.single.failCancelDraftTitle'),
content: t('order.placeOrder.single.failCancelDraftContent'),
okText: t('order.placeOrder.single.failCancelDraftOk'),
cancelText: t('order.placeOrder.single.failCancelKeep'),
okButtonProps: { status: 'danger' },
onOk: async () => {
createFailCancelling.value = true;
try {
await postOrderDelete({ id: info.orderId! });
Message.success(t('order.placeOrder.single.failCancelDraftDone'));
closeCreateFailModal();
resetSingleForm();
} catch (e: any) {
Message.error(e?.message || t('order.placeOrder.single.failCancelDraftFailed'));
} finally {
createFailCancelling.value = false;
}
},
});
return;
}
Modal.confirm({
title: t('order.placeOrder.single.failAbandonTitle'),
content: t('order.placeOrder.single.failAbandonContent'),
okText: t('order.placeOrder.single.failAbandonOk'),
cancelText: t('order.placeOrder.single.failCancelKeep'),
okButtonProps: { status: 'danger' },
onOk: () => {
closeCreateFailModal();
resetSingleForm();
Message.success(t('order.placeOrder.single.failAbandonDone'));
},
});
}
async function handleSingleOrderSubmit() {
const validationError = getSingleFormValidationError();
if (validationError) {
@ -2191,7 +2420,7 @@
);
void openSenderModal();
} else {
Message.error(e?.message || t('order.placeOrder.single.failed'));
openCreateFailModal(e);
}
} finally {
singleSubmitting.value = false;
@ -3006,6 +3235,98 @@
</div>
</a-modal>
<a-modal
v-model:visible="createFailVisible"
:title="
createFailInfo?.draftSaved
? t('order.placeOrder.single.failTitleDraft')
: t('order.placeOrder.single.failTitle')
"
:footer="false"
width="520px"
unmount-on-close
:mask-closable="!createFailCancelling && !createFailSavingDraft"
:esc-to-close="!createFailCancelling && !createFailSavingDraft"
>
<div v-if="createFailInfo" class="create-fail-wrap">
<div class="create-fail-section">
<div class="create-fail-label">{{ t('order.placeOrder.single.failWhatHappened') }}</div>
<p class="create-fail-reason">{{ createFailInfo.reason }}</p>
<p
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
class="create-fail-raw"
>
{{ t('order.placeOrder.single.failRawReason') }}:{{ createFailInfo.rawReason }}
</p>
<p v-if="createFailInfo.draftSaved" class="create-fail-draft-note">
{{ t('order.placeOrder.single.failDraftSavedNote') }}
<template v-if="createFailInfo.serviceNo">
({{ t('order.placeOrder.single.failServiceNo') }}:
<button type="button" class="create-fail-copy" @click="handleCreateFailCopyServiceNo">
{{ createFailInfo.serviceNo }}
</button>
)
</template>
</p>
<p v-else class="create-fail-draft-note">
{{ t('order.placeOrder.single.failNotSavedNote') }}
</p>
</div>
<div class="create-fail-section">
<div class="create-fail-label">{{ t('order.placeOrder.single.failHowToFix') }}</div>
<ul class="create-fail-tips">
<li v-for="(tip, idx) in createFailInfo.tips" :key="idx">{{ tip }}</li>
</ul>
</div>
<div class="create-fail-actions">
<a-button
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailCancelSubmit"
>
{{
createFailInfo.draftSaved
? t('order.placeOrder.single.failCancelSubmit')
: t('order.placeOrder.single.failAbandon')
}}
</a-button>
<a-button
v-if="createFailInfo.suggestSupport || createFailInfo.draftSaved"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailAskSupport"
>
{{ t('order.placeOrder.single.failAskSupport') }}
</a-button>
<a-button
v-if="createFailInfo.draftSaved"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailGoDrafts"
>
{{ t('order.placeOrder.single.failGoDrafts') }}
</a-button>
<a-button
v-if="!createFailInfo.draftSaved"
:disabled="createFailCancelling"
:loading="createFailSavingDraft"
@click="handleCreateFailSaveAsDraft"
>
{{ t('order.placeOrder.single.failSaveAsDraft') }}
</a-button>
<a-button
type="primary"
:disabled="createFailSavingDraft"
:loading="createFailCancelling"
@click="handleCreateFailContinueEdit"
>
{{
createFailInfo.draftSaved
? t('order.placeOrder.single.failEditDraft')
: t('order.placeOrder.single.failContinueEdit')
}}
</a-button>
</div>
</div>
</a-modal>
<a-modal
v-model:visible="senderModalVisible"
:title="t('order.placeOrder.single.senderModalTitle')"
@ -4232,6 +4553,70 @@
}
}
.create-fail-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.create-fail-section {
.create-fail-label {
margin-bottom: 6px;
color: #1d2129;
font-size: 13px;
font-weight: 600;
}
.create-fail-reason {
margin: 0;
color: #4e5969;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.create-fail-raw {
margin: 6px 0 0;
color: #86909c;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
.create-fail-draft-note {
margin: 8px 0 0;
color: var(--app-primary-color);
font-size: 13px;
line-height: 1.5;
}
.create-fail-copy {
border: none;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.create-fail-tips {
margin: 0;
padding-left: 18px;
color: #4e5969;
font-size: 13px;
line-height: 1.7;
}
}
.create-fail-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
.single-tab {
display: flex;
flex-direction: column;

View File

@ -113,6 +113,30 @@
</div>
</div>
<div v-if="predeclareFailGuide" class="predeclare-fail-guide">
<div class="predeclare-fail-guide__title">
{{ $t('order.myOrders.drawer.failGuideTitle') }}
</div>
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
<p
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
class="predeclare-fail-guide__raw"
>
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }}
</p>
<ul class="predeclare-fail-guide__tips">
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li>
</ul>
<div class="predeclare-fail-guide__actions">
<a-button size="small" type="outline" @click="handleAskAdvisor">
{{ $t('order.myOrders.drawer.failGuideAskSupport') }}
</a-button>
<a-button size="small" type="primary" @click="startBasicEdit">
{{ $t('order.myOrders.drawer.failGuideEdit') }}
</a-button>
</div>
</div>
<div class="basic-view-switch">
<a-radio-group
v-model="basicViewMode"
@ -674,6 +698,14 @@
</a-spin>
</div>
<!-- 附件 -->
<div v-if="activeTab === 'attachments'" class="tab-panel attachments-info">
<OrderAttachmentsTab
:order-id="orderId"
:active="activeTab === 'attachments'"
/>
</div>
<!-- 绑定包裹/库存 -->
<div v-if="activeTab === 'stockBind'" class="tab-panel stock-bind-info">
<OrderStockBindTab
@ -711,7 +743,9 @@ import { getDictListAll, type DictDto } from '@/api/system/dict';
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
import AddOrderFeeModal from './AddOrderFeeModal.vue';
import OrderStockBindTab from '../../myOrders/components/OrderStockBindTab.vue';
import OrderAttachmentsTab from '../../components/OrderAttachmentsTab.vue';
import { openAskAdvisor } from '@/composables/useAskAdvisor';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
const { t } = useI18n();
const router = useRouter();
@ -767,6 +801,14 @@ const currentOrderStatusMeta = computed(() => {
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 {
@ -1007,6 +1049,7 @@ const tabList = computed(() => [
{ key: 'weight', label: t('order.myOrders.drawer.tabs.weight') },
{ key: 'fee', label: t('order.myOrders.drawer.tabs.fee') },
{ 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') },
]);
@ -1921,7 +1964,7 @@ watch(
);
defineExpose({
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'stockBind' }) => {
openDrawer: (opts?: { tab?: 'basic' | 'weight' | 'fee' | 'track' | 'attachments' | 'stockBind' }) => {
activeTab.value = opts?.tab || 'basic';
basicViewMode.value = 'all';
feeViewMode.value = 'all';
@ -2101,6 +2144,50 @@ defineExpose({
margin-top: 12px;
}
.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 {
display: inline-flex;
align-items: center;

View File

@ -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';
@ -1220,8 +1221,11 @@
if (colKey === 'predeclareFailReason') {
const reason = rowData?.predeclareFailReason || value || '';
if (!reason) return null;
const escaped = escapeHtml(String(reason));
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
const guide = buildFailGuideFromReason(String(reason));
const display = guide.reason || String(reason);
const escaped = escapeHtml(display);
const title = escapeHtml(guide.rawReason || String(reason));
return `<span title="${title}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
}
if (colKey === 'allocationPendingReason') {
const reason = rowData?.allocationPendingReason || value || '';
@ -1295,12 +1299,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<string, any>;
delete nextQuery.orderId;
delete nextQuery.edit;
router.replace({ query: nextQuery });
}
}
watch(
() => route.query.status,
() => {
@ -1316,6 +1339,14 @@
loadTabCounts();
},
);
watch(
() => route.query.orderId,
(orderId) => {
if (!orderId) return;
void tryOpenOrderFromQuery();
},
);
</script>
<template>