Commit d3d42d61809504d652ca2f4ec22b9c3a468c7996

Authored by “wangming”
1 parent 81aab8ac

```

feat(utils): 支持退款模式的收付款显示格式化

- 添加可选参数 `opts` 配置对象,支持 `mode`, `skLabel`, `fkLabel`,
  `fallbackText` 选项
- 新增 refund 模式,收付款段前缀分别显示「退款」「退回」
- 兜底文案从「组合支付」改为「组合退款」
- 优化参数处理逻辑

fix(todoCenter): 商品调价单审核提示更新

- 更新商品调价单审核提示信息,明确说明审核后调后成本将写入
  该仓库商品成本(wt_sp_cost.cbj)

feat(wtCgthd): 序列号展示优化

- 序列号列宽度调整至 200px
- 实现序列号预览功能,超出 8 个则仅显示前 4 个 + 查看按钮
- 添加序列号列表弹窗,展示完整序列号清单
- 新增相关计算方法和对话框组件

feat(wtDyDpsz): 抖音店铺配置功能增强

- 扩展表单布局至 820px 宽度
- 增加启用状态、同步天数等字段
- 添加抖音开放平台配置区域(AppKey/AppSecret)
- 新增发货人信息配置(姓名、电话、地址等)
- 表格视图增加出库仓库、AppKey、同步天数等列
- 实现出库仓库数据加载和展示

fix(forms): 商品选择清空处理

- 在多个表单组件中添加商品编号为空时的数据清空逻辑
- 清空商品名称、单位、数量、单价、金额等相关字段
- 重置序列号加载状态和列表数据

docs(wtPriceAdjust): 商品调价单文档更新

- 将调价单功能从调后售价改为调后成本
- 更新表格列标题:「调前售价」→「调前成本」
- 更新输入框占位符:「价格」→「新成本」
- 修正审核提示文本,说明调后成本写入仓库商品成本
```
Showing 58 changed files with 2935 additions and 772 deletions
Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js
... ... @@ -59,11 +59,22 @@ export function parseWtMxJsonArray(val) {
59 59 *
60 60 * @param {Object} row - { skzh, skmx }
61 61 * @param {Array} skzhOptions - 与 dynamicText 一致的字典选项
  62 + * @param {Object} [opts] - 可选配置
  63 + * - mode: 'sale' | 'refund',默认 'sale';refund 模式下
  64 + * skmx 段前缀显示「退款」,fkmx 段前缀显示「退回」,
  65 + * 并把 `组合支付` 文案兜底为「组合退款」
  66 + * - skLabel / fkLabel: 显式指定段前缀,优先级高于 mode
  67 + * - fallbackText: 无法解析时兜底文字
62 68 * @returns {string}
63 69 */
64   -export function formatWtSkzhDisplay(row, skzhOptions) {
  70 +export function formatWtSkzhDisplay(row, skzhOptions, opts) {
65 71 if (!row || row.skzh === null || row.skzh === undefined || row.skzh === '') return '无'
66 72 const options = skzhOptions || []
  73 + const o = opts || {}
  74 + const mode = o.mode === 'refund' ? 'refund' : 'sale'
  75 + const skLabel = o.skLabel || (mode === 'refund' ? '退款' : '收款')
  76 + const fkLabel = o.fkLabel || (mode === 'refund' ? '退回' : '付款')
  77 + const fallback = o.fallbackText || (mode === 'refund' ? '组合退款' : '组合支付')
67 78 if (row.skzh === '组合支付' && (row.skmx || row.fkmx)) {
68 79 try {
69 80 const { list: skList } = row.skmx ? parseWtMxJsonArray(row.skmx) : { list: [] }
... ... @@ -98,11 +109,11 @@ export function formatWtSkzhDisplay(row, skzhOptions) {
98 109 }
99 110 }
100 111 const seg = []
101   - if (skParts.length) seg.push(`收款:${skParts.join(';')}`)
102   - if (fkParts.length) seg.push(`付款:${fkParts.join(';')}`)
103   - return seg.join('|') || '组合支付'
  112 + if (skParts.length) seg.push(`${skLabel}:${skParts.join(';')}`)
  113 + if (fkParts.length) seg.push(`${fkLabel}:${fkParts.join(';')}`)
  114 + return seg.join('|') || fallback
104 115 } catch (e) {
105   - return '组合支付'
  116 + return fallback
106 117 }
107 118 }
108 119 const byDict = resolveSkzhDictionaryLabel(row.skzh, options)
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue
... ... @@ -654,7 +654,7 @@ export default {
654 654 }
655 655 if (t === "商品调价单") {
656 656 return {
657   - msg: "确认审核该商品调价单?通过后调后售价将写入商品档案零售价。",
  657 + msg: "确认审核该商品调价单?通过后调后成本将写入该仓库商品成本(wt_sp_cost.cbj)。",
658 658 title: "审核确认"
659 659 };
660 660 }
... ... @@ -697,7 +697,7 @@ export default {
697 697 }
698 698 if (t === "商品调价单") {
699 699 return {
700   - msg: "确认二级审核该商品调价单?通过后调后售价将写入商品档案零售价。",
  700 + msg: "确认二级审核该商品调价单?通过后调后成本将写入该仓库商品成本(wt_sp_cost.cbj)。",
701 701 title: "二级审核确认"
702 702 };
703 703 }
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue
... ... @@ -1639,6 +1639,18 @@
1639 1639 return label.includes(query.toLowerCase());
1640 1640 },
1641 1641 async handleProductChange(row) {
  1642 + if (!row.spbh) {
  1643 + this.$set(row, 'spmc', '')
  1644 + this.$set(row, 'dw', '')
  1645 + this.$set(row, 'sl', undefined)
  1646 + this.$set(row, 'dj', undefined)
  1647 + this.$set(row, 'je', undefined)
  1648 + this.$set(row, 'description', '')
  1649 + this.$set(row, 'kucun', undefined)
  1650 + this.$set(row, 'spxlhLoaded', false)
  1651 + this.$set(row, 'xlhList', [])
  1652 + return
  1653 + }
1642 1654 // 选中商品后可自动回填商品名称等信息
1643 1655 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1644 1656 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue
... ... @@ -1422,6 +1422,18 @@
1422 1422 return label.includes(query.toLowerCase());
1423 1423 },
1424 1424 handleProductChange(row) {
  1425 + if (!row.spbh) {
  1426 + this.$set(row, 'spmc', '')
  1427 + this.$set(row, 'dw', '')
  1428 + this.$set(row, 'sl', undefined)
  1429 + this.$set(row, 'dj', undefined)
  1430 + this.$set(row, 'je', undefined)
  1431 + this.$set(row, 'description', '')
  1432 + this.$set(row, 'kucun', undefined)
  1433 + this.$set(row, 'spxlhLoaded', false)
  1434 + this.$set(row, 'xlhList', [])
  1435 + return
  1436 + }
1425 1437 // 选中商品后可自动回填商品名称等信息
1426 1438 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1427 1439 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue
... ... @@ -1422,6 +1422,18 @@
1422 1422 return label.includes(query.toLowerCase());
1423 1423 },
1424 1424 handleProductChange(row) {
  1425 + if (!row.spbh) {
  1426 + this.$set(row, 'spmc', '')
  1427 + this.$set(row, 'dw', '')
  1428 + this.$set(row, 'sl', undefined)
  1429 + this.$set(row, 'dj', undefined)
  1430 + this.$set(row, 'je', undefined)
  1431 + this.$set(row, 'description', '')
  1432 + this.$set(row, 'kucun', undefined)
  1433 + this.$set(row, 'spxlhLoaded', false)
  1434 + this.$set(row, 'xlhList', [])
  1435 + return
  1436 + }
1425 1437 // 选中商品后可自动回填商品名称等信息
1426 1438 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1427 1439 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue
... ... @@ -343,6 +343,14 @@ setFullName(item,row){
343 343 }
344 344 },
345 345 handleProductChange(row) {
  346 + if (!row.spbh) {
  347 + this.$set(row, 'spmc', '')
  348 + this.$set(row, 'dw', '')
  349 + this.$set(row, 'sl', undefined)
  350 + this.$set(row, 'dj', undefined)
  351 + this.$set(row, 'je', undefined)
  352 + return
  353 + }
346 354 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
347 355 if (product) {
348 356 row.spmc = product.F_Spmc || '';
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue
... ... @@ -648,6 +648,20 @@
648 648 this.updateTotalAmount();
649 649 },
650 650 async handleProductChange(row) {
  651 + if (!row.spbh) {
  652 + this.$set(row, 'spmc', '')
  653 + this.$set(row, 'sptm', '')
  654 + this.$set(row, 'dw', '')
  655 + this.$set(row, 'sl', undefined)
  656 + this.$set(row, 'dj', '')
  657 + this.$set(row, 'thdj', '')
  658 + this.$set(row, 'je', '')
  659 + this.$set(row, 'description', '')
  660 + this.$set(row, 'dyddbh', undefined)
  661 + this.$set(row, 'spxlhLoaded', false)
  662 + this.$set(row, 'xlhList', [])
  663 + return
  664 + }
651 665 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
652 666 if (product) {
653 667 row.spmc = product.F_Spmc || '';
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue
... ... @@ -116,17 +116,27 @@
116 116 <span class="cell-nowrap mx-cell__money">{{ formatMoneyCol(scope.row.je) }}</span>
117 117 </template>
118 118 </el-table-column>
119   - <el-table-column label="序列号" min-width="180">
  119 + <el-table-column label="序列号" min-width="200">
120 120 <template slot-scope="scope">
121   - <div v-if="scope.row.selectedSerialNumbers && scope.row.selectedSerialNumbers.length" class="sn-tags">
122   - <el-tag
123   - v-for="(sn, idx) in scope.row.selectedSerialNumbers"
124   - :key="idx"
125   - size="mini"
126   - type="warning"
127   - class="sn-tag"
128   - >{{ sn }}</el-tag>
129   - </div>
  121 + <template v-if="serialList(scope.row).length">
  122 + <div class="sn-tags">
  123 + <el-tag
  124 + v-for="(sn, idx) in serialPreview(scope.row)"
  125 + :key="idx"
  126 + size="mini"
  127 + type="warning"
  128 + class="sn-tag"
  129 + >{{ sn }}</el-tag>
  130 + <el-button
  131 + v-if="serialList(scope.row).length > snInlineMax"
  132 + type="text"
  133 + class="sn-more-btn"
  134 + @click="openSerialDialog(scope.row)"
  135 + >
  136 + 共 {{ serialList(scope.row).length }} 个,点击查看
  137 + </el-button>
  138 + </div>
  139 + </template>
130 140 <span v-else class="text-muted">无</span>
131 141 </template>
132 142 </el-table-column>
... ... @@ -179,6 +189,26 @@
179 189 <span>暂无数据</span>
180 190 </div>
181 191 </div>
  192 +
  193 + <el-dialog
  194 + title="序列号列表"
  195 + :visible.sync="serialDialogVisible"
  196 + width="560px"
  197 + append-to-body
  198 + class="NCC-dialog cgthd-sn-list-dialog"
  199 + @closed="serialDialogList = []"
  200 + >
  201 + <div class="sn-dialog-hint">共 {{ serialDialogList.length }} 条</div>
  202 + <div class="sn-dialog-tags">
  203 + <el-tag
  204 + v-for="(sn, idx) in serialDialogList"
  205 + :key="idx"
  206 + size="small"
  207 + type="warning"
  208 + class="sn-dialog-tag"
  209 + >{{ sn }}</el-tag>
  210 + </div>
  211 + </el-dialog>
182 212 </el-dialog>
183 213 </template>
184 214  
... ... @@ -196,7 +226,12 @@ export default {
196 226 loading: false,
197 227 detail: null,
198 228 ckckOptions: [],
199   - skzhOptions: []
  229 + skzhOptions: [],
  230 + /** 明细格内最多直接展示的序列号个数,超出则仅预览前若干 + 点击查看 */
  231 + snInlineMax: 8,
  232 + snPreviewCount: 4,
  233 + serialDialogVisible: false,
  234 + serialDialogList: []
200 235 }
201 236 },
202 237 computed: {
... ... @@ -215,6 +250,20 @@ export default {
215 250 }
216 251 },
217 252 methods: {
  253 + serialList(row) {
  254 + const raw = row && row.selectedSerialNumbers
  255 + if (!raw || !raw.length) return []
  256 + return raw.map(s => (s == null ? '' : String(s)).trim()).filter(Boolean)
  257 + },
  258 + serialPreview(row) {
  259 + const all = this.serialList(row)
  260 + if (all.length <= this.snInlineMax) return all
  261 + return all.slice(0, this.snPreviewCount)
  262 + },
  263 + openSerialDialog(row) {
  264 + this.serialDialogList = this.serialList(row).slice()
  265 + this.serialDialogVisible = true
  266 + },
218 267 labelFromOptions(value, options) {
219 268 if (value === null || value === undefined || value === '') return '无'
220 269 const opts = options || []
... ... @@ -261,6 +310,8 @@ export default {
261 310 return sums
262 311 },
263 312 handleClose() {
  313 + this.serialDialogVisible = false
  314 + this.serialDialogList = []
264 315 this.detail = null
265 316 this.$emit('close')
266 317 },
... ... @@ -450,6 +501,29 @@ export default {
450 501 .sn-tag {
451 502 margin: 0 !important;
452 503 }
  504 +.sn-more-btn {
  505 + padding: 0 4px !important;
  506 + margin-left: 2px;
  507 + vertical-align: middle;
  508 + font-size: 12px;
  509 +}
  510 +.sn-dialog-hint {
  511 + font-size: 12px;
  512 + color: #909399;
  513 + margin: -6px 0 10px;
  514 +}
  515 +.sn-dialog-tags {
  516 + max-height: min(420px, 60vh);
  517 + overflow-y: auto;
  518 + display: flex;
  519 + flex-wrap: wrap;
  520 + gap: 6px;
  521 + align-content: flex-start;
  522 + padding: 2px 0;
  523 +}
  524 +.sn-dialog-tag {
  525 + margin: 0 !important;
  526 +}
453 527 .detail-bz {
454 528 white-space: pre-wrap;
455 529 word-break: break-all;
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue
... ... @@ -414,6 +414,7 @@
414 414 this.$set(row, 'xlh', '');
415 415 this.$set(row, 'selectedSerialNumbers', []);
416 416 this.$set(row, 'zmkc', 0);
  417 + this.$set(row, 'sl', undefined);
417 418 this.$set(row, 'cbdj', '');
418 419 this.$set(row, 'cbje', '');
419 420 }
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue
1 1 <template>
2   - <el-dialog :title="!dataForm.id ? '新建' : '编辑'" :close-on-click-modal="false" :visible.sync="visible" class="NCC-dialog NCC-dialog_center" lock-scroll width="600px">
  2 + <el-dialog :title="!dataForm.id ? '新建' : '编辑'" :close-on-click-modal="false" :visible.sync="visible" class="NCC-dialog NCC-dialog_center" lock-scroll width="820px">
3 3 <el-row :gutter="15">
4   - <el-form ref="elForm" :model="dataForm" size="small" label-width="100px" label-position="right" :rules="rules">
  4 + <el-form ref="elForm" :model="dataForm" size="small" label-width="95px" label-position="right" :rules="rules">
5 5 <el-col :span="24">
  6 + <el-divider content-position="left">基础信息</el-divider>
  7 + </el-col>
  8 + <el-col :span="12">
6 9 <el-form-item label="店铺ID" prop="shopId">
7 10 <el-input-number v-model="dataForm.shopId" :min="0" :precision="0" placeholder="抖音店铺ID" controls-position="right" style="width:100%" />
8 11 </el-form-item>
9 12 </el-col>
10   - <el-col :span="24">
  13 + <el-col :span="12">
11 14 <el-form-item label="店铺名称" prop="shopName">
12 15 <el-input v-model="dataForm.shopName" placeholder="店铺名称" clearable style="width:100%" />
13 16 </el-form-item>
14 17 </el-col>
  18 + <el-col :span="12">
  19 + <el-form-item label="启用状态" prop="enabled">
  20 + <el-select v-model="dataForm.enabled" placeholder="启用状态" style="width:100%">
  21 + <el-option label="启用" :value="1" />
  22 + <el-option label="停用" :value="0" />
  23 + </el-select>
  24 + </el-form-item>
  25 + </el-col>
  26 + <el-col :span="12">
  27 + <el-form-item label="同步天数" prop="syncDays">
  28 + <el-input-number v-model="dataForm.syncDays" :min="1" :max="365" :precision="0" controls-position="right" style="width:100%" />
  29 + </el-form-item>
  30 + </el-col>
  31 +
  32 + <el-col :span="24">
  33 + <el-divider content-position="left">抖音开放平台配置</el-divider>
  34 + </el-col>
  35 + <el-col :span="24">
  36 + <el-form-item label="AppKey" prop="appKey">
  37 + <el-input v-model="dataForm.appKey" placeholder="抖音开放平台 AppKey" clearable style="width:100%" />
  38 + </el-form-item>
  39 + </el-col>
15 40 <el-col :span="24">
  41 + <el-form-item label="AppSecret" prop="appSecret">
  42 + <el-input v-model="dataForm.appSecret" placeholder="抖音开放平台 AppSecret" show-password clearable style="width:100%" />
  43 + </el-form-item>
  44 + </el-col>
  45 + <el-col :span="24">
  46 + <el-form-item label="回调地址" prop="callbackUrl">
  47 + <el-input v-model="dataForm.callbackUrl" placeholder="授权回调地址,例如 http://xxx/api/auth/callback" clearable style="width:100%" />
  48 + </el-form-item>
  49 + </el-col>
  50 + <el-col :span="24">
  51 + <el-form-item label="API 地址" prop="apiBaseUrl">
  52 + <el-input v-model="dataForm.apiBaseUrl" placeholder="默认 https://openapi-fxg.jinritemai.com" clearable style="width:100%" />
  53 + </el-form-item>
  54 + </el-col>
  55 +
  56 + <el-col :span="24">
  57 + <el-divider content-position="left">ERP 关联</el-divider>
  58 + </el-col>
  59 + <el-col :span="12">
16 60 <el-form-item label="往来单位" prop="kh">
17 61 <el-select v-model="dataForm.kh" placeholder="请选择往来单位" clearable filterable style="width:100%">
18 62 <el-option v-for="item in khList" :key="item.id" :label="item.dwmc" :value="item.id" />
19 63 </el-select>
20 64 </el-form-item>
21 65 </el-col>
22   - <el-col :span="24">
  66 + <el-col :span="12">
23 67 <el-form-item label="收款账户" prop="skzh">
24 68 <el-select v-model="dataForm.skzh" placeholder="请选择收款账户" clearable style="width:100%">
25 69 <el-option v-for="item in skzhList" :key="item.id" :label="item.fullName" :value="item.id" />
... ... @@ -27,6 +71,56 @@
27 71 </el-form-item>
28 72 </el-col>
29 73 <el-col :span="24">
  74 + <el-form-item label="出库仓库" prop="ck">
  75 + <el-select v-model="dataForm.ck" placeholder="请选择出库仓库(抖音发货单专用)" clearable filterable style="width:100%">
  76 + <el-option v-for="item in ckList" :key="item.F_Id || item.id" :label="item.F_mdmc || item.mdmc" :value="item.F_Id || item.id" />
  77 + </el-select>
  78 + </el-form-item>
  79 + </el-col>
  80 +
  81 + <el-col :span="24">
  82 + <el-divider content-position="left">发货人信息</el-divider>
  83 + </el-col>
  84 + <el-col :span="12">
  85 + <el-form-item label="发货人姓名" prop="senderName">
  86 + <el-input v-model="dataForm.senderName" placeholder="发货人姓名" clearable style="width:100%" />
  87 + </el-form-item>
  88 + </el-col>
  89 + <el-col :span="12">
  90 + <el-form-item label="发货人电话" prop="senderPhone">
  91 + <el-input v-model="dataForm.senderPhone" placeholder="发货人电话" clearable style="width:100%" />
  92 + </el-form-item>
  93 + </el-col>
  94 + <el-col :span="8">
  95 + <el-form-item label="省份" prop="senderProvince">
  96 + <el-input v-model="dataForm.senderProvince" placeholder="省份" clearable style="width:100%" />
  97 + </el-form-item>
  98 + </el-col>
  99 + <el-col :span="8">
  100 + <el-form-item label="城市" prop="senderCity">
  101 + <el-input v-model="dataForm.senderCity" placeholder="城市" clearable style="width:100%" />
  102 + </el-form-item>
  103 + </el-col>
  104 + <el-col :span="8">
  105 + <el-form-item label="区县" prop="senderDistrict">
  106 + <el-input v-model="dataForm.senderDistrict" placeholder="区县" clearable style="width:100%" />
  107 + </el-form-item>
  108 + </el-col>
  109 + <el-col :span="8">
  110 + <el-form-item label="街道" prop="senderStreet">
  111 + <el-input v-model="dataForm.senderStreet" placeholder="街道(可选)" clearable style="width:100%" />
  112 + </el-form-item>
  113 + </el-col>
  114 + <el-col :span="16">
  115 + <el-form-item label="详细地址" prop="senderAddress">
  116 + <el-input v-model="dataForm.senderAddress" placeholder="详细地址(街道门牌等)" clearable style="width:100%" />
  117 + </el-form-item>
  118 + </el-col>
  119 +
  120 + <el-col :span="24">
  121 + <el-divider content-position="left">备注</el-divider>
  122 + </el-col>
  123 + <el-col :span="24">
30 124 <el-form-item label="备注" prop="bz">
31 125 <el-input v-model="dataForm.bz" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="可选" clearable style="width:100%" />
32 126 </el-form-item>
... ... @@ -41,30 +135,50 @@
41 135 </template>
42 136 <script>
43 137 import request from '@/utils/request'
  138 +
  139 +const emptyForm = () => ({
  140 + id: undefined,
  141 + shopId: undefined,
  142 + shopName: undefined,
  143 + kh: undefined,
  144 + skzh: undefined,
  145 + ck: undefined,
  146 + bz: undefined,
  147 + appKey: undefined,
  148 + appSecret: undefined,
  149 + callbackUrl: undefined,
  150 + apiBaseUrl: 'https://openapi-fxg.jinritemai.com',
  151 + syncDays: 30,
  152 + senderName: undefined,
  153 + senderPhone: undefined,
  154 + senderAddress: undefined,
  155 + senderProvince: undefined,
  156 + senderCity: undefined,
  157 + senderDistrict: undefined,
  158 + senderStreet: undefined,
  159 + enabled: 1
  160 +})
  161 +
44 162 export default {
45 163 props: {
46 164 khList: { type: Array, default: () => [] },
47   - skzhList: { type: Array, default: () => [] }
  165 + skzhList: { type: Array, default: () => [] },
  166 + ckList: { type: Array, default: () => [] }
48 167 },
49 168 data() {
50 169 return {
51 170 visible: false,
52   - dataForm: {
53   - id: undefined,
54   - shopId: undefined,
55   - shopName: undefined,
56   - kh: undefined,
57   - skzh: undefined,
58   - bz: undefined
59   - },
  171 + dataForm: emptyForm(),
60 172 rules: {
61 173 shopId: [{ required: true, message: '请输入店铺ID', trigger: 'change' }],
62   - shopName: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }]
  174 + shopName: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
  175 + appKey: [{ required: true, message: '请输入 AppKey', trigger: 'blur' }]
63 176 }
64 177 }
65 178 },
66 179 methods: {
67 180 init(id) {
  181 + this.dataForm = emptyForm()
68 182 this.dataForm.id = id || 0
69 183 this.visible = true
70 184 this.$nextTick(() => {
... ... @@ -84,18 +198,23 @@ export default {
84 198 shopName: d.shopName,
85 199 kh: d.kh !== undefined && d.kh !== null && d.kh !== '' ? d.kh : undefined,
86 200 skzh: d.skzh !== undefined && d.skzh !== null && d.skzh !== '' ? d.skzh : undefined,
87   - bz: d.bz
  201 + ck: d.ck !== undefined && d.ck !== null && d.ck !== '' ? d.ck : undefined,
  202 + bz: d.bz,
  203 + appKey: d.appKey || '',
  204 + appSecret: d.appSecret || '',
  205 + callbackUrl: d.callbackUrl || '',
  206 + apiBaseUrl: d.apiBaseUrl || 'https://openapi-fxg.jinritemai.com',
  207 + syncDays: d.syncDays || 30,
  208 + senderName: d.senderName || '',
  209 + senderPhone: d.senderPhone || '',
  210 + senderAddress: d.senderAddress || '',
  211 + senderProvince: d.senderProvince || '',
  212 + senderCity: d.senderCity || '',
  213 + senderDistrict: d.senderDistrict || '',
  214 + senderStreet: d.senderStreet || '',
  215 + enabled: d.enabled === 0 ? 0 : 1
88 216 }
89 217 })
90   - } else {
91   - this.dataForm = {
92   - id: undefined,
93   - shopId: undefined,
94   - shopName: undefined,
95   - kh: undefined,
96   - skzh: undefined,
97   - bz: undefined
98   - }
99 218 }
100 219 })
101 220 },
... ... @@ -107,7 +226,21 @@ export default {
107 226 shopName: this.dataForm.shopName,
108 227 kh: this.dataForm.kh,
109 228 skzh: this.dataForm.skzh,
110   - bz: this.dataForm.bz
  229 + ck: this.dataForm.ck,
  230 + bz: this.dataForm.bz,
  231 + appKey: this.dataForm.appKey,
  232 + appSecret: this.dataForm.appSecret,
  233 + callbackUrl: this.dataForm.callbackUrl,
  234 + apiBaseUrl: this.dataForm.apiBaseUrl,
  235 + syncDays: this.dataForm.syncDays,
  236 + senderName: this.dataForm.senderName,
  237 + senderPhone: this.dataForm.senderPhone,
  238 + senderAddress: this.dataForm.senderAddress,
  239 + senderProvince: this.dataForm.senderProvince,
  240 + senderCity: this.dataForm.senderCity,
  241 + senderDistrict: this.dataForm.senderDistrict,
  242 + senderStreet: this.dataForm.senderStreet,
  243 + enabled: this.dataForm.enabled
111 244 }
112 245 if (!this.dataForm.id) {
113 246 request({
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/index.vue
... ... @@ -59,6 +59,35 @@
59 59 {{ getSkzhName(scope.row.skzh) }}
60 60 </template>
61 61 </el-table-column>
  62 + <el-table-column label="出库仓库" align="left" min-width="160" show-overflow-tooltip>
  63 + <template slot-scope="scope">
  64 + {{ getCkName(scope.row.ck) }}
  65 + </template>
  66 + </el-table-column>
  67 + <el-table-column label="AppKey" align="left" min-width="180" show-overflow-tooltip>
  68 + <template slot-scope="scope">
  69 + {{ scope.row.appKey || '未配置' }}
  70 + </template>
  71 + </el-table-column>
  72 + <el-table-column label="同步天数" align="center" width="90">
  73 + <template slot-scope="scope">
  74 + {{ scope.row.syncDays || 30 }}
  75 + </template>
  76 + </el-table-column>
  77 + <el-table-column label="发货人" align="left" min-width="160" show-overflow-tooltip>
  78 + <template slot-scope="scope">
  79 + <span v-if="scope.row.senderName || scope.row.senderPhone">
  80 + {{ scope.row.senderName || '' }}<span v-if="scope.row.senderPhone"> / {{ scope.row.senderPhone }}</span>
  81 + </span>
  82 + <span v-else>未配置</span>
  83 + </template>
  84 + </el-table-column>
  85 + <el-table-column label="状态" align="center" width="80">
  86 + <template slot-scope="scope">
  87 + <el-tag v-if="(scope.row.enabled === undefined || scope.row.enabled === null || scope.row.enabled === 1)" type="success" size="mini">启用</el-tag>
  88 + <el-tag v-else type="info" size="mini">停用</el-tag>
  89 + </template>
  90 + </el-table-column>
62 91 <el-table-column prop="bz" label="备注" align="left" min-width="140" show-overflow-tooltip>
63 92 <template slot-scope="scope">
64 93 {{ scope.row.bz || '无' }}
... ... @@ -74,7 +103,7 @@
74 103 </div>
75 104 </el-card>
76 105 </div>
77   - <NCC-Form v-if="formVisible" ref="NCCForm" :kh-list="khList" :skzh-list="skzhList" @refresh="refresh" />
  106 + <NCC-Form v-if="formVisible" ref="NCCForm" :kh-list="khList" :skzh-list="skzhList" :ck-list="ckList" @refresh="refresh" />
78 107 </div>
79 108 </template>
80 109 <script>
... ... @@ -91,7 +120,8 @@ export default {
91 120 listLoading: true,
92 121 formVisible: false,
93 122 khList: [],
94   - skzhList: []
  123 + skzhList: [],
  124 + ckList: []
95 125 }
96 126 },
97 127 computed: {
... ... @@ -109,6 +139,7 @@ export default {
109 139 this.initData()
110 140 this.loadKhList()
111 141 this.loadSkzhList()
  142 + this.loadCkList()
112 143 },
113 144 methods: {
114 145 initData() {
... ... @@ -146,6 +177,28 @@ export default {
146 177 this.skzhList = []
147 178 })
148 179 },
  180 + loadCkList() {
  181 + request({
  182 + url: '/api/Extend/WtCk',
  183 + method: 'GET',
  184 + data: { currentPage: 1, pageSize: 1000 }
  185 + }).then(res => {
  186 + const d = res.data
  187 + if (Array.isArray(d)) {
  188 + this.ckList = d
  189 + } else if (d && Array.isArray(d.list)) {
  190 + this.ckList = d.list
  191 + } else if (d && d.data && Array.isArray(d.data.list)) {
  192 + this.ckList = d.data.list
  193 + } else if (d && Array.isArray(d.data)) {
  194 + this.ckList = d.data
  195 + } else {
  196 + this.ckList = []
  197 + }
  198 + }).catch(() => {
  199 + this.ckList = []
  200 + })
  201 + },
149 202 getKhName(id) {
150 203 if (id === undefined || id === null || id === '') return '无'
151 204 const item = this.khList.find(x => x.id === id || String(x.id) === String(id))
... ... @@ -156,6 +209,12 @@ export default {
156 209 const item = this.skzhList.find(x => x.id === id || String(x.id) === String(id))
157 210 return item ? (item.fullName || '无') : '无'
158 211 },
  212 + getCkName(id) {
  213 + if (id === undefined || id === null || id === '') return '无'
  214 + const key = String(id)
  215 + const item = this.ckList.find(x => String(x.F_Id || x.id) === key)
  216 + return item ? (item.F_mdmc || item.mdmc || '无') : '无'
  217 + },
159 218 handleDel(id) {
160 219 this.$confirm('此操作将永久删除该店铺设置, 是否继续?', '提示', {
161 220 type: 'warning'
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtHzd/Form.vue
... ... @@ -1293,6 +1293,18 @@
1293 1293 return label.includes(query.toLowerCase());
1294 1294 },
1295 1295 handleProductChange(row) {
  1296 + if (!row.spbh) {
  1297 + this.$set(row, 'spmc', '')
  1298 + this.$set(row, 'dw', '')
  1299 + this.$set(row, 'sl', undefined)
  1300 + this.$set(row, 'dj', undefined)
  1301 + this.$set(row, 'je', undefined)
  1302 + this.$set(row, 'description', '')
  1303 + this.$set(row, 'kucun', undefined)
  1304 + this.$set(row, 'spxlhLoaded', false)
  1305 + this.$set(row, 'xlhList', [])
  1306 + return
  1307 + }
1296 1308 // 选中商品后可自动回填商品名称等信息
1297 1309 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1298 1310 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtPdd/Form.vue
... ... @@ -1447,6 +1447,18 @@
1447 1447 return label.includes(query.toLowerCase());
1448 1448 },
1449 1449 handleProductChange(row) {
  1450 + if (!row.spbh) {
  1451 + this.$set(row, 'spmc', '')
  1452 + this.$set(row, 'dw', '')
  1453 + this.$set(row, 'sl', undefined)
  1454 + this.$set(row, 'dj', undefined)
  1455 + this.$set(row, 'je', undefined)
  1456 + this.$set(row, 'description', '')
  1457 + this.$set(row, 'kucun', undefined)
  1458 + this.$set(row, 'spxlhLoaded', false)
  1459 + this.$set(row, 'xlhList', [])
  1460 + return
  1461 + }
1450 1462 // 选中商品后可自动回填商品名称等信息
1451 1463 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1452 1464 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtPriceAdjust/Form.vue
... ... @@ -110,12 +110,7 @@
110 110 <span :class="{ 'text-warning': scope.row.sl === 0 }">{{ scope.row.sl }}</span>
111 111 </template>
112 112 </el-table-column>
113   - <el-table-column prop="cbj" label="成本(参考)" width="96" align="right">
114   - <template slot-scope="scope">
115   - <span class="tjd-ref-price">{{ formatPrice(scope.row.cbj) }}</span>
116   - </template>
117   - </el-table-column>
118   - <el-table-column prop="tqdj" label="调前售价" width="90" align="right">
  113 + <el-table-column prop="tqdj" label="调前成本" width="96" align="right">
119 114 <template slot-scope="scope">
120 115 <span :class="{ 'text-warning': !(parseFloat(scope.row.tqdj) > 0) }">{{ formatPrice(scope.row.tqdj) }}</span>
121 116 </template>
... ... @@ -127,15 +122,15 @@
127 122 </el-input>
128 123 </template>
129 124 </el-table-column>
130   - <el-table-column prop="thdj" label="调后售价" width="100" class-name="tjd-editable-col">
  125 + <el-table-column prop="thdj" label="调后成本" width="100" class-name="tjd-editable-col">
131 126 <template slot-scope="scope">
132   - <el-input v-model="scope.row.thdj" size="mini" placeholder="价格" :disabled="!!isDetail" @input="handleNewPriceChange(scope.row)" />
  127 + <el-input v-model="scope.row.thdj" size="mini" placeholder="新成本" :disabled="!!isDetail" @input="handleNewPriceChange(scope.row)" />
133 128 </template>
134 129 </el-table-column>
135   - <el-table-column prop="tqje" label="调前金额" width="90" align="right">
  130 + <el-table-column prop="tqje" label="调前成本总额" width="110" align="right">
136 131 <template slot-scope="scope">{{ formatPrice(scope.row.tqje) }}</template>
137 132 </el-table-column>
138   - <el-table-column prop="thje" label="调后金额" width="90" align="right">
  133 + <el-table-column prop="thje" label="调后成本总额" width="110" align="right">
139 134 <template slot-scope="scope">
140 135 <span class="text-primary" v-if="scope.row.thje">{{ formatPrice(scope.row.thje) }}</span>
141 136 <span v-else>-</span>
... ... @@ -316,7 +311,7 @@
316 311 }
317 312 this.$set(row, 'ck', this.dataForm.ck || '')
318 313 },
319   - // 调后售价 = 调前售价 × (比率% / 100),例如 100 为原价、110 为按 110% 计价
  314 + // 调后成本 = 调前成本 × (比率% / 100),例如 100 为原价、110 为按 110% 计价
320 315 handleRatioChange(row) {
321 316 const tqdj = parseFloat(row.tqdj) || 0
322 317 const tjbl = parseFloat(row.tjbl)
... ... @@ -384,7 +379,7 @@
384 379 },
385 380 handleSubmitForAudit() {
386 381 if (!this.dataForm.id) return
387   - this.$confirm('确认提交审核?提交后将进入待办中心,由审核人员在待办中审批;通过后调后售价写入商品档案零售价。', '提交审核', { type: 'warning' }).then(() => {
  382 + this.$confirm('确认提交审核?提交后将进入待办中心,由审核人员在待办中审批;通过后调后成本写入该仓库商品成本(wt_sp_cost.cbj)。', '提交审核', { type: 'warning' }).then(() => {
388 383 request({ url: `/api/Extend/WtTjd/Actions/SubmitForAudit/${this.dataForm.id}`, method: 'POST' }).then(res => {
389 384 this.$message({ type: 'success', message: res.msg || '已提交', duration: 1000, onClose: () => { this.visible = false; this.$emit('refresh', true) } })
390 385 })
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtPriceAdjust/detail-view.vue
... ... @@ -60,17 +60,17 @@
60 60 </el-table-column>
61 61 <el-table-column prop="spbm" label="编码" width="100" show-overflow-tooltip />
62 62 <el-table-column prop="sl" label="库存" width="72" align="right" />
63   - <el-table-column label="调前售价" width="88" align="right">
  63 + <el-table-column label="调前成本" width="92" align="right">
64 64 <template slot-scope="scope">{{ formatMoney(scope.row.tqdj) }}</template>
65 65 </el-table-column>
66 66 <el-table-column prop="tjbl" label="比率%" width="72" align="right" />
67   - <el-table-column label="调后售价" width="88" align="right">
  67 + <el-table-column label="调后成本" width="92" align="right">
68 68 <template slot-scope="scope">{{ formatMoney(scope.row.thdj) }}</template>
69 69 </el-table-column>
70   - <el-table-column label="调前金额" width="88" align="right">
  70 + <el-table-column label="调前成本总额" width="108" align="right">
71 71 <template slot-scope="scope">{{ formatMoney(scope.row.tqje) }}</template>
72 72 </el-table-column>
73   - <el-table-column label="调后金额" width="88" align="right">
  73 + <el-table-column label="调后成本总额" width="108" align="right">
74 74 <template slot-scope="scope">{{ formatMoney(scope.row.thje) }}</template>
75 75 </el-table-column>
76 76 </el-table>
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/Form.vue
... ... @@ -78,23 +78,6 @@
78 78 </el-form-item>
79 79 </el-col>
80 80 <el-col :xs="24" :sm="8">
81   - <el-form-item label="抖音SKU(可多个)" prop="dyspidList">
82   - <el-select
83   - :key="'wt-sp-dyspid-' + (dataForm.id || 'new')"
84   - v-model="dataForm.dyspidList"
85   - multiple
86   - filterable
87   - allow-create
88   - default-first-option
89   - collapse-tags
90   - placeholder="选填,输入 SKU 后回车添加,可多个"
91   - clearable
92   - class="wt-sp-form__fullwidth"
93   - @change="handleDyspidListChange"
94   - />
95   - </el-form-item>
96   - </el-col>
97   - <el-col :xs="24" :sm="8">
98 81 <el-form-item label="序列号类型" prop="spxlhType">
99 82 <el-select v-model="dataForm.spxlhType" placeholder="请选择" clearable class="wt-sp-form__fullwidth">
100 83 <el-option
... ... @@ -128,6 +111,61 @@
128 111 <el-input v-model="dataForm.kc" placeholder="根据在库序列号统计,不可修改" readonly />
129 112 </el-form-item>
130 113 </el-col>
  114 + <el-col :span="24">
  115 + <el-form-item label="抖音SKU" prop="dyspidList" class="wt-sp-form-item--stack">
  116 + <div class="wt-sp-dyspid-editor">
  117 + <div v-if="!dataForm.dyspidList || !dataForm.dyspidList.length" class="wt-sp-dyspid-empty">
  118 + <span>该商品暂未配置抖音 SKU</span>
  119 + <el-button
  120 + v-if="!isDetail"
  121 + type="primary"
  122 + plain
  123 + size="small"
  124 + icon="el-icon-plus"
  125 + @click="addDyspid"
  126 + >添加抖音 SKU</el-button>
  127 + </div>
  128 + <template v-else>
  129 + <div class="wt-sp-dyspid-list">
  130 + <div
  131 + v-for="(sku, idx) in dataForm.dyspidList"
  132 + :key="'dyspid_' + idx"
  133 + class="wt-sp-dyspid-row"
  134 + >
  135 + <span class="wt-sp-dyspid-badge">SKU{{ idx + 1 }}</span>
  136 + <el-input
  137 + :value="sku"
  138 + :placeholder="'请输入抖音 SKU ID(第 ' + (idx + 1) + ' 条)'"
  139 + :disabled="!!isDetail"
  140 + clearable
  141 + class="wt-sp-dyspid-input"
  142 + @input="(v) => updateDyspid(idx, v)"
  143 + @blur="normalizeDyspidRow(idx)"
  144 + />
  145 + <el-button
  146 + v-if="!isDetail"
  147 + type="text"
  148 + icon="el-icon-delete"
  149 + class="wt-sp-dyspid-del"
  150 + title="删除此 SKU"
  151 + @click="removeDyspid(idx)"
  152 + />
  153 + </div>
  154 + </div>
  155 + <div v-if="!isDetail" class="wt-sp-dyspid-footer">
  156 + <el-button
  157 + type="primary"
  158 + plain
  159 + size="small"
  160 + icon="el-icon-plus"
  161 + @click="addDyspid"
  162 + >添加抖音 SKU</el-button>
  163 + <span class="wt-sp-dyspid-hint">已配置 {{ dataForm.dyspidList.length }} 条;抖音后台每个商品规格(SKU)对应一行,可维护多个以支持一品多规;保存时会自动去空与去重。</span>
  164 + </div>
  165 + </template>
  166 + </div>
  167 + </el-form-item>
  168 + </el-col>
131 169 </el-row>
132 170  
133 171 <!-- 二、展示与规则:主图 + 限高详情 + 规则并排 -->
... ... @@ -269,6 +307,37 @@
269 307 hyxz: undefined
270 308 },
271 309 rules: {
  310 + spbm: [
  311 + {
  312 + validator: (rule, value, callback) => {
  313 + const code = value == null ? '' : String(value).trim()
  314 + if (!code) {
  315 + callback()
  316 + return
  317 + }
  318 + if (this._spbmCheckTimer) clearTimeout(this._spbmCheckTimer)
  319 + this._spbmCheckTimer = setTimeout(() => {
  320 + request({
  321 + url: '/api/Extend/WtSp/Actions/CheckSpbm',
  322 + method: 'GET',
  323 + data: { spbm: code, id: this.dataForm.id || '' }
  324 + }).then(res => {
  325 + const data = (res && res.data) || res || {}
  326 + const payload = data.data || data
  327 + if (payload && payload.exists) {
  328 + const who = payload.spmc ? `(${payload.spmc})` : ''
  329 + callback(new Error(`商品编码已存在${who},请更换`))
  330 + } else {
  331 + callback()
  332 + }
  333 + }).catch(() => {
  334 + callback()
  335 + })
  336 + }, 250)
  337 + },
  338 + trigger: 'blur'
  339 + }
  340 + ],
272 341 tcfs: [{ required: true, message: '请选择提成方式', trigger: 'change' }],
273 342 tcfs_j: [
274 343 {
... ... @@ -364,19 +433,50 @@
364 433 }
365 434 return out
366 435 },
367   - handleDyspidListChange(val) {
368   - const next = this.normalizeDyspidList(val)
369   - const cur = this.dataForm.dyspidList
370   - const needSync =
371   - !Array.isArray(cur) ||
372   - cur.length !== next.length ||
373   - next.some((s, i) => String(cur[i] == null ? '' : cur[i]).trim() !== s)
374   - if (needSync) {
375   - this.$nextTick(() => {
376   - this.$set(this.dataForm, 'dyspidList', next.slice())
377   - })
  436 + /** 新版抖音 SKU 编辑器:逐行输入 */
  437 + ensureDyspidListArray() {
  438 + if (!Array.isArray(this.dataForm.dyspidList)) {
  439 + this.$set(this.dataForm, 'dyspidList', [])
  440 + }
  441 + },
  442 + syncDyspidCompat() {
  443 + const list = Array.isArray(this.dataForm.dyspidList) ? this.dataForm.dyspidList : []
  444 + const firstNonEmpty = list.map(v => (v == null ? '' : String(v).trim())).find(v => v !== '')
  445 + this.dataForm.dyspid = firstNonEmpty || undefined
  446 + },
  447 + /** el-input 输入时同步到指定索引(数组索引直接赋值在 Vue 2 下不是响应式,须用 $set) */
  448 + updateDyspid(idx, val) {
  449 + this.ensureDyspidListArray()
  450 + this.$set(this.dataForm.dyspidList, idx, val == null ? '' : String(val))
  451 + },
  452 + /** 添加一行空 SKU */
  453 + addDyspid() {
  454 + this.ensureDyspidListArray()
  455 + this.dataForm.dyspidList.push('')
  456 + },
  457 + /** 删除一行 SKU */
  458 + removeDyspid(idx) {
  459 + if (!Array.isArray(this.dataForm.dyspidList)) return
  460 + if (idx < 0 || idx >= this.dataForm.dyspidList.length) return
  461 + this.dataForm.dyspidList.splice(idx, 1)
  462 + this.syncDyspidCompat()
  463 + },
  464 + /** 失焦时对当前行做 trim + 去重校验,重复项给出提示 */
  465 + normalizeDyspidRow(idx) {
  466 + if (!Array.isArray(this.dataForm.dyspidList)) return
  467 + const raw = this.dataForm.dyspidList[idx]
  468 + const trimmed = raw == null ? '' : String(raw).trim()
  469 + if (trimmed !== raw) {
  470 + this.$set(this.dataForm.dyspidList, idx, trimmed)
  471 + }
  472 + if (trimmed) {
  473 + const dupIdx = this.dataForm.dyspidList.findIndex((v, i) => i !== idx && String(v == null ? '' : v).trim() === trimmed)
  474 + if (dupIdx !== -1) {
  475 + this.$message.warning('该抖音 SKU 已存在,已自动清空重复项')
  476 + this.$set(this.dataForm.dyspidList, idx, '')
  477 + }
378 478 }
379   - this.dataForm.dyspid = next.length ? next[0] : undefined
  479 + this.syncDyspidCompat()
380 480 },
381 481 /** 编辑回显:优先 dyspidList(数组或兼容字符串),否则单字段 dyspid */
382 482 hydrateDyspidFields() {
... ... @@ -727,6 +827,70 @@
727 827 .wt-sp-form-item--quill >>> .el-form-item__content {
728 828 line-height: normal;
729 829 }
  830 +/* 抖音 SKU 编辑器:每行独立可见可操作 */
  831 +.wt-sp-dyspid-editor {
  832 + background: #fafbfc;
  833 + border: 1px solid #ebeef5;
  834 + border-radius: 4px;
  835 + padding: 10px 12px;
  836 +}
  837 +.wt-sp-dyspid-empty {
  838 + display: flex;
  839 + align-items: center;
  840 + gap: 12px;
  841 + color: #909399;
  842 + font-size: 13px;
  843 +}
  844 +.wt-sp-dyspid-list {
  845 + display: flex;
  846 + flex-direction: column;
  847 + gap: 8px;
  848 +}
  849 +.wt-sp-dyspid-row {
  850 + display: flex;
  851 + align-items: center;
  852 + gap: 10px;
  853 +}
  854 +.wt-sp-dyspid-badge {
  855 + flex: 0 0 auto;
  856 + display: inline-flex;
  857 + align-items: center;
  858 + justify-content: center;
  859 + min-width: 52px;
  860 + height: 28px;
  861 + padding: 0 10px;
  862 + background: #ecf5ff;
  863 + color: #409eff;
  864 + border-radius: 14px;
  865 + font-size: 12px;
  866 + font-weight: 600;
  867 + letter-spacing: 0.5px;
  868 +}
  869 +.wt-sp-dyspid-input {
  870 + flex: 1 1 auto;
  871 + min-width: 0;
  872 +}
  873 +.wt-sp-dyspid-del {
  874 + flex: 0 0 auto;
  875 + color: #f56c6c !important;
  876 + padding: 4px 6px !important;
  877 +}
  878 +.wt-sp-dyspid-del:hover {
  879 + color: #c45656 !important;
  880 +}
  881 +.wt-sp-dyspid-footer {
  882 + display: flex;
  883 + align-items: center;
  884 + gap: 12px;
  885 + margin-top: 10px;
  886 + padding-top: 10px;
  887 + border-top: 1px dashed #e4e7ed;
  888 +}
  889 +.wt-sp-dyspid-hint {
  890 + color: #909399;
  891 + font-size: 12px;
  892 + line-height: 1.6;
  893 +}
730 894 </style>
731 895 <style>
732 896 /* 弹层:保留足够上内边距,避免首段「档案与定价」等贴顶被裁切;与 NCC-dialog 左右留白协调 */
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/index.vue
... ... @@ -99,14 +99,14 @@
99 99 <screenfull isContainer />
100 100 </div>
101 101 </div>
102   - <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
103   - <el-table-column prop="spmc" label="商品名称" align="left" min-width="260" class-name="wt-sp-spmc-col">
  102 + <NCC-table v-loading="listLoading" class="wt-sp-table" :data="list" has-c @selection-change="handleSelectionChange">
  103 + <el-table-column prop="spmc" label="商品名称" align="left" min-width="220" show-overflow-tooltip>
104 104 <template slot-scope="scope">
105 105 <i class="el-icon-goods wt-sp-col-icon wt-sp-col-icon--primary" aria-hidden="true" />
106 106 <span>{{ scope.row.spmc || '无' }}</span>
107 107 </template>
108 108 </el-table-column>
109   - <el-table-column label="商品品类" prop="pl" align="left" min-width="130" show-overflow-tooltip>
  109 + <el-table-column label="商品品类" prop="pl" align="left" min-width="120" show-overflow-tooltip>
110 110 <template slot-scope="scope">
111 111 <i class="el-icon-menu wt-sp-col-icon wt-sp-col-icon--success" aria-hidden="true" />
112 112 <span>{{ scope.row.pl || '无' }}</span>
... ... @@ -124,7 +124,7 @@
124 124 <span>{{ scope.row.spbm || '无' }}</span>
125 125 </template>
126 126 </el-table-column>
127   - <el-table-column label="抖音SKU(可多个)" align="left" min-width="160" show-overflow-tooltip>
  127 + <el-table-column label="抖音SKU(可多个)" align="left" min-width="180" show-overflow-tooltip>
128 128 <template slot-scope="scope">
129 129 <i class="el-icon-price-tag wt-sp-col-icon wt-sp-col-icon--info" aria-hidden="true" />
130 130 <span>{{ formatDyspidListCell(scope.row) }}</span>
... ... @@ -136,36 +136,24 @@
136 136 <span>{{ dynText(scope.row.spxlhType, spxlhTypeOptions) }}</span>
137 137 </template>
138 138 </el-table-column>
139   - <el-table-column prop="lsj" label="零售价" align="right" width="96" show-overflow-tooltip>
  139 + <el-table-column prop="lsj" label="零售价" align="right" min-width="96" show-overflow-tooltip>
140 140 <template slot-scope="scope">
141 141 <i class="el-icon-coin wt-sp-col-icon wt-sp-col-icon--warning" aria-hidden="true" />
142   - <span>{{ scope.row.lsj }}</span>
  142 + <span>{{ formatLsjCell(scope.row) }}</span>
143 143 </template>
144 144 </el-table-column>
145   - <el-table-column prop="zg" label="限购" align="right" width="72" show-overflow-tooltip>
  145 + <el-table-column prop="zg" label="限购" align="right" min-width="72" show-overflow-tooltip>
146 146 <template slot-scope="scope">
147 147 <i class="el-icon-warning-outline wt-sp-col-icon wt-sp-col-icon--info" aria-hidden="true" />
148 148 <span>{{ scope.row.zg != null && scope.row.zg !== '' ? scope.row.zg : '无' }}</span>
149 149 </template>
150 150 </el-table-column>
151   - <el-table-column label="实时库存" align="right" width="102" show-overflow-tooltip>
  151 + <el-table-column label="实时库存" align="right" min-width="96" show-overflow-tooltip>
152 152 <template slot-scope="scope">
153 153 <i class="el-icon-box wt-sp-col-icon wt-sp-col-icon--success" aria-hidden="true" />
154 154 <span>{{ formatRealtimeKc(scope.row) }}</span>
155 155 </template>
156 156 </el-table-column>
157   - <el-table-column prop="shgz" label="售后规则" align="left" min-width="120" show-overflow-tooltip>
158   - <template slot-scope="scope">
159   - <i class="el-icon-service wt-sp-col-icon wt-sp-col-icon--info" aria-hidden="true" />
160   - <span>{{ scope.row.shgz || '无' }}</span>
161   - </template>
162   - </el-table-column>
163   - <el-table-column prop="yfgz" label="运费规则" align="left" min-width="120" show-overflow-tooltip>
164   - <template slot-scope="scope">
165   - <i class="el-icon-truck wt-sp-col-icon wt-sp-col-icon--primary" aria-hidden="true" />
166   - <span>{{ scope.row.yfgz || '无' }}</span>
167   - </template>
168   - </el-table-column>
169 157 <el-table-column label="销售渠道" prop="xsqd" align="left" min-width="100" show-overflow-tooltip>
170 158 <template slot-scope="scope">
171 159 <i class="el-icon-s-shop wt-sp-col-icon wt-sp-col-icon--success" aria-hidden="true" />
... ... @@ -417,6 +405,12 @@
417 405 if (row.kc !== undefined && row.kc !== null && row.kc !== '') return String(row.kc);
418 406 return '0';
419 407 },
  408 + formatLsjCell(row) {
  409 + if (!row || row.lsj === undefined || row.lsj === null || row.lsj === '') return '无';
  410 + const n = Number(row.lsj);
  411 + if (!Number.isNaN(n)) return n.toFixed(2);
  412 + return String(row.lsj);
  413 + },
420 414 /** 列表展示:优先 dyspidList,兼容旧字段 dyspid */
421 415 formatDyspidListCell(row) {
422 416 if (!row) return '无'
... ... @@ -454,13 +448,9 @@
454 448 }
455 449 </script>
456 450 <style scoped>
457   -/* 商品名称列允许多行换行,尽量展示完整(与项目 NCC-table 单行策略区分:仅本列表) */
458   -::v-deep .wt-sp-spmc-col .cell {
459   - white-space: normal !important;
460   - word-break: break-word;
461   - line-height: 1.45;
462   - padding-top: 8px;
463   - padding-bottom: 8px;
  451 +/* 列表单元格单行不换行,过长由 show-overflow-tooltip 悬停查看 */
  452 +::v-deep .wt-sp-table .el-table .cell {
  453 + white-space: nowrap;
464 454 }
465 455 .wt-sp-col-icon {
466 456 margin-right: 6px;
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtTjdbd/Form.vue
... ... @@ -1516,6 +1516,18 @@
1516 1516 return label.includes(query.toLowerCase());
1517 1517 },
1518 1518 async handleProductChange(row) {
  1519 + if (!row.spbh) {
  1520 + this.$set(row, 'spmc', '')
  1521 + this.$set(row, 'dw', '')
  1522 + this.$set(row, 'sl', undefined)
  1523 + this.$set(row, 'dj', undefined)
  1524 + this.$set(row, 'je', undefined)
  1525 + this.$set(row, 'description', '')
  1526 + this.$set(row, 'kucun', undefined)
  1527 + this.$set(row, 'spxlhLoaded', false)
  1528 + this.$set(row, 'xlhList', [])
  1529 + return
  1530 + }
1519 1531 // 选中商品后可自动回填商品名称等信息
1520 1532 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1521 1533 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/Form.vue
... ... @@ -252,18 +252,6 @@
252 252 </el-input>
253 253 </el-form-item>
254 254 </el-col>
255   - <el-col :span="8">
256   - <el-form-item label="审核人" prop="shr">
257   - <user-select v-model="dataForm.shr" placeholder="请选择" clearable >
258   - </user-select>
259   - </el-form-item>
260   - </el-col>
261   - <el-col :span="8">
262   - <el-form-item label="过账人" prop="gzr">
263   - <user-select v-model="dataForm.gzr" placeholder="请选择" clearable >
264   - </user-select>
265   - </el-form-item>
266   - </el-col>
267 255 <el-col :span="24">
268 256 <el-form-item label="备注" prop="bz">
269 257 <el-input v-model="dataForm.bz" placeholder="请输入" show-word-limit :style='{"width":"100%"}' type='textarea' :autosize='{"minRows":4,"maxRows":4}' >
... ... @@ -725,17 +713,7 @@
725 713 else{
726 714 _this.dataForm.ly = '后台';
727 715 _this.dataForm.djlx = '销售出库单';
728   - // 新建时加载默认选项设置
729   - request({ url: '/api/Extend/WtMrsz', method: 'get' }).then(res => {
730   - if (res.data) {
731   - if (res.data.mrck) {
732   - _this.dataForm.cjck = res.data.mrck
733   - _this.dataForm.cjckId = res.data.mrck
734   - }
735   - if (res.data.mrwldw) _this.dataForm.kh = res.data.mrwldw
736   - if (res.data.mrskzh) _this.dataForm.skzh = res.data.mrskzh
737   - }
738   - }).catch(() => {})
  716 + // 新建销售出库单:不再加载默认仓库/往来单位/收款账户,由用户手动选择
739 717 }
740 718 })
741 719 },
... ... @@ -1583,7 +1561,12 @@
1583 1561 this.$set(row, 'cbje', undefined)
1584 1562 this.$set(row, 'dj', undefined)
1585 1563 this.$set(row, 'je', undefined)
  1564 + this.$set(row, 'sl', undefined)
1586 1565 this.$set(row, 'spmc', '')
  1566 + this.$set(row, 'description', '')
  1567 + this.$set(row, 'kucun', undefined)
  1568 + this.$set(row, 'spxlhLoaded', false)
  1569 + this.$set(row, 'xlhList', [])
1587 1570 return
1588 1571 }
1589 1572 // 选中商品后可自动回填商品名称等信息
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/detail-view.vue
... ... @@ -73,6 +73,12 @@
73 73 {{ cellText(detail.jsr) }}
74 74 </span>
75 75 </el-descriptions-item>
  76 + <el-descriptions-item v-if="detail.fhr" label="发货人">
  77 + <span class="cell-nowrap">
  78 + <i class="el-icon-truck desc-icon desc-icon--primary" />
  79 + {{ cellText(detail.fhr) }}
  80 + </span>
  81 + </el-descriptions-item>
76 82 <el-descriptions-item label="会员手机">
77 83 <span class="cell-nowrap">
78 84 <i class="el-icon-phone desc-icon desc-icon--info" />
... ... @@ -366,11 +372,11 @@ export default {
366 372 displayCollectionRaw() {
367 373 const row = this.detail
368 374 if (!row) return 0
369   - const hasYdje = row.ydje != null && row.ydje !== ''
370   - if (hasYdje) {
371   - const ydje = parseFloat(row.ydje)
372   - const ysje = parseFloat(row.ysje) || 0
373   - return !isNaN(ydje) ? ydje - ysje : 0
  375 + // 与列表页保持一致:优先使用数据库真实存储的 skje,
  376 + // 兼容抖音单(用户支付≠商家实收)的场景;skje 缺失再回退
  377 + if (row.skje != null && row.skje !== '') {
  378 + const skje = parseFloat(row.skje)
  379 + if (!isNaN(skje)) return skje
374 380 }
375 381 if (row.skmx || row.fkmx) {
376 382 let sum = 0
... ... @@ -384,7 +390,11 @@ export default {
384 390 }
385 391 if (sum > 0) return sum
386 392 }
387   - if (row.skje != null && row.skje !== '') return parseFloat(row.skje)
  393 + if (row.ydje != null && row.ydje !== '') {
  394 + const ydje = parseFloat(row.ydje)
  395 + const ysje = parseFloat(row.ysje) || 0
  396 + if (!isNaN(ydje)) return ydje - ysje
  397 + }
388 398 return 0
389 399 },
390 400 displayCollectionAmount() {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/index.vue
... ... @@ -74,15 +74,6 @@
74 74 </el-form-item>
75 75 </el-col>
76 76 <el-col :span="6">
77   - <el-form-item label="单据类型">
78   - <el-select v-model="query.djlx" placeholder="单据类型" clearable>
79   - <el-option label="销售出库单" value="销售出库单" />
80   - <el-option label="预售出库单" value="预售出库单" />
81   - </el-select>
82   - </el-form-item>
83   - </el-col>
84   -
85   - <el-col :span="6">
86 77 <el-form-item label="单据来源">
87 78 <el-select v-model="query.ly" placeholder="单据来源" clearable>
88 79 <el-option label="后台" value="后台" />
... ... @@ -131,6 +122,9 @@
131 122 <el-table-column prop="jsr" label="经手人" align="left" min-width="88" show-overflow-tooltip>
132 123 <template slot-scope="scope">{{ displayText(scope.row.jsr) }}</template>
133 124 </el-table-column>
  125 + <el-table-column prop="fhr" label="发货人" align="left" min-width="88" show-overflow-tooltip>
  126 + <template slot-scope="scope">{{ displayText(scope.row.fhr) }}</template>
  127 + </el-table-column>
134 128 <el-table-column prop="hysjh" label="会员手机号码" align="left" min-width="120" show-overflow-tooltip>
135 129 <template slot-scope="scope">{{ displayText(scope.row.hysjh) }}</template>
136 130 </el-table-column>
... ... @@ -155,19 +149,18 @@
155 149 <el-table-column prop="bz" label="备注" align="left" min-width="120" show-overflow-tooltip>
156 150 <template slot-scope="scope">{{ displayText(scope.row.bz) }}</template>
157 151 </el-table-column>
158   - <el-table-column prop="yddh" label="运单号" align="left" min-width="130" show-overflow-tooltip>
159   - <template slot-scope="scope">{{ displayText(scope.row.yddh) }}</template>
160   - </el-table-column>
161   - <el-table-column prop="dyddh" label="抖音订单号" align="left" min-width="150" show-overflow-tooltip>
  152 + <el-table-column prop="dyddh" label="抖音订单号" align="left" min-width="160" show-overflow-tooltip>
162 153 <template slot-scope="scope">{{ displayText(scope.row.dyddh) }}</template>
163 154 </el-table-column>
  155 + <el-table-column prop="yddh" label="物流运单号" align="left" min-width="140" show-overflow-tooltip>
  156 + <template slot-scope="scope">{{ displayText(scope.row.yddh) }}</template>
  157 + </el-table-column>
164 158 <el-table-column prop="sy_pch" label="收银批次" align="left" min-width="120" show-overflow-tooltip />
165 159 <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip>
166 160 <template slot-scope="scope">
167 161 <ncc-table-summary-cell :row="scope.row" fields="zy,Zy" />
168 162 </template>
169 163 </el-table-column>
170   - <el-table-column prop="djlx" label="单据类型" align="left" min-width="100" show-overflow-tooltip />
171 164 <el-table-column label="单据来源" prop="ly" align="left" min-width="110" show-overflow-tooltip>
172 165 <template slot-scope="scope">{{ formatLy(scope.row.ly) }}</template>
173 166 </el-table-column>
... ... @@ -250,7 +243,7 @@
250 243 currentPage: 1,
251 244 pageSize: 20,
252 245 sort: "desc",
253   - sidx: "",
  246 + sidx: "id",
254 247 },
255 248 formVisible: false,
256 249 detailVisible: false,
... ... @@ -260,17 +253,17 @@
260 253 { prop: 'djrq', label: '单据日期' },
261 254 { prop: 'cjck', label: '出库仓库' },
262 255 { prop: 'jsr', label: '经手人' },
  256 + { prop: 'fhr', label: '发货人' },
263 257 { prop: 'skzh', label: '收款账户' },
264 258 { prop: 'skje', label: '收款金额' },
265 259 { prop: 'ysje', label: '优惠金额' },
266 260 { prop: 'shr', label: '审核人' },
267 261 { prop: 'gzr', label: '过账人' },
268 262 { prop: 'bz', label: '备注' },
269   - { prop: 'yddh', label: '运单号' },
270 263 { prop: 'dyddh', label: '抖音订单号' },
  264 + { prop: 'yddh', label: '物流运单号' },
271 265 { prop: 'sy_pch', label: '收银批次' },
272 266 { prop: 'zy', label: '摘要' },
273   - { prop: 'djlx', label: '单据类型' },
274 267 { prop: 'thdh', label: '关联退货单号' },
275 268 ],
276 269 cjckOptions : [],
... ... @@ -378,13 +371,14 @@
378 371 const ysje = parseFloat(row.ysje) || 0;
379 372 return ysje.toFixed(2);
380 373 },
381   - // ✅ 获取显示的收款金额(与编辑页一致:有原价时 收款=原价-优惠,否则 skmx 汇总或 skje)
  374 + // ✅ 获取显示的收款金额
  375 + // 优先使用后端存储的真实收款金额 skje(抖音单会出现 skje ≠ ydje-ysje 的情况:
  376 + // 用户支付 49,但商家实收 9.9,平台扣费 39.1,这时必须看 skje 而不是 ydje-ysje)
  377 + // skje 缺失时才回退到 skmx/fkmx 汇总,最后才回退到老口径 ydje-ysje,保持对旧数据兼容
382 378 getDisplayCollectionAmount(row) {
383   - const hasYdje = row.ydje != null && row.ydje !== '';
384   - if (hasYdje) {
385   - const ydje = parseFloat(row.ydje);
386   - const ysje = parseFloat(row.ysje) || 0;
387   - return (!isNaN(ydje) ? (ydje - ysje).toFixed(2) : null) || '0.00';
  379 + if (row.skje != null && row.skje !== '') {
  380 + const skje = parseFloat(row.skje);
  381 + if (!isNaN(skje)) return skje.toFixed(2);
388 382 }
389 383 if (row.skmx || row.fkmx) {
390 384 try {
... ... @@ -406,7 +400,12 @@
406 400 console.error('解析组合支付明细失败:', e);
407 401 }
408 402 }
409   - return (row.skje != null && row.skje !== '') ? parseFloat(row.skje).toFixed(2) : '0.00';
  403 + if (row.ydje != null && row.ydje !== '') {
  404 + const ydje = parseFloat(row.ydje);
  405 + const ysje = parseFloat(row.ysje) || 0;
  406 + if (!isNaN(ydje)) return (ydje - ysje).toFixed(2);
  407 + }
  408 + return '0.00';
410 409 },
411 410 getcjckOptions(){
412 411 previewDataInterface('681758216954053893').then(res => {
... ... @@ -441,6 +440,8 @@
441 440 query[key] = _query[key]
442 441 }
443 442 }
  443 + // 本页为"销售出库单"专属页,强制只查 销售出库单(不含 预售/退货/代销等同表其他单据)
  444 + query.djlx = '销售出库单'
444 445 request({
445 446 url: `/api/Extend/WtXsckd`,
446 447 method: 'GET',
... ... @@ -599,7 +600,7 @@
599 600 currentPage: 1,
600 601 pageSize: 20,
601 602 sort: "desc",
602   - sidx: "",
  603 + sidx: "id",
603 604 }
604 605 this.initData()
605 606 },
... ... @@ -616,7 +617,7 @@
616 617 currentPage: 1,
617 618 pageSize: 20,
618 619 sort: 'desc',
619   - sidx: ''
  620 + sidx: 'id'
620 621 }
621 622 this.initData()
622 623 }
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/Form.vue
... ... @@ -612,6 +612,18 @@
612 612 this.updateTotalAmount();
613 613 },
614 614 async handleProductChange(row) {
  615 + if (!row.spbh) {
  616 + this.$set(row, 'spmc', '')
  617 + this.$set(row, 'sptm', '')
  618 + this.$set(row, 'dw', '')
  619 + this.$set(row, 'sl', undefined)
  620 + this.$set(row, 'dj', undefined)
  621 + this.$set(row, 'je', undefined)
  622 + this.$set(row, 'description', '')
  623 + this.$set(row, 'spxlhLoaded', false)
  624 + this.$set(row, 'xlhList', [])
  625 + return
  626 + }
615 627 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
616 628 console.log('选中商品', row.spbh, product);
617 629 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/detail-view.vue
... ... @@ -164,7 +164,7 @@
164 164 <el-descriptions-item label="退款账户">
165 165 <span class="cell-nowrap">
166 166 <i class="el-icon-bank-card desc-icon desc-icon--success" />
167   - {{ labelFromOptions(detail.skzh, skzhOptions) }}
  167 + {{ displaySkzh() }}
168 168 </span>
169 169 </el-descriptions-item>
170 170 <el-descriptions-item label="审核人">
... ... @@ -207,6 +207,7 @@ import { previewDataInterface } from &#39;@/api/systemData/dataInterface&#39;
207 207 import { dynamicText } from '@/filters'
208 208 import XsckdDetailView from '../wtXsckd/detail-view'
209 209 import { getAccountSelector } from '@/api/extend/wtAccount'
  210 +import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay'
210 211  
211 212 export default {
212 213 name: 'WtXsthdDetailView',
... ... @@ -267,6 +268,10 @@ export default {
267 268 if (this.$refs.XsckdDetailView) this.$refs.XsckdDetailView.init(id)
268 269 })
269 270 },
  271 + displaySkzh() {
  272 + if (!this.detail) return '无'
  273 + return formatWtSkzhDisplay(this.detail, this.skzhOptions, { mode: 'refund' }) || '无'
  274 + },
270 275 labelFromOptions(value, options) {
271 276 if (value === null || value === undefined || value === '') return '无'
272 277 const opts = options || []
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/index.vue
... ... @@ -94,8 +94,8 @@
94 94 <screenfull isContainer />
95 95 </div>
96 96 </div>
97   - <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
98   - <el-table-column prop="id" label="单据编号" align="left" />
  97 + <NCC-table v-loading="listLoading" class="wt-xsthd-table" :data="list" has-c @selection-change="handleSelectionChange">
  98 + <el-table-column prop="id" label="单据编号" align="left" min-width="150" show-overflow-tooltip />
99 99 <el-table-column label="销售出库单号" align="left" min-width="150" show-overflow-tooltip>
100 100 <template slot-scope="scope">
101 101 <span class="cell-nowrap" @click.stop>
... ... @@ -110,22 +110,20 @@
110 110 </span>
111 111 </template>
112 112 </el-table-column>
113   - <el-table-column prop="djrq" label="单据日期" align="left" :formatter="ncc.tableDateFormat" />
114   - <el-table-column label="入库仓库" prop="cjck" align="left">
  113 + <el-table-column prop="djrq" label="单据日期" align="left" min-width="110" :formatter="ncc.tableDateFormat" show-overflow-tooltip />
  114 + <el-table-column label="入库仓库" prop="cjck" align="left" min-width="120" show-overflow-tooltip>
115 115 <template slot-scope="scope">{{ formatListCjck(scope.row) }}</template>
116 116 </el-table-column>
117   - <el-table-column prop="jsr" label="经手人" align="left" />
118   - <!-- <el-table-column prop="ysje" label="优惠金额" align="left" /> -->
119   - <el-table-column label="退款账户" prop="skzh" align="left">
120   - <template slot-scope="scope">{{ scope.row.skzh | dynamicText(skzhOptions) }}</template>
  117 + <el-table-column prop="jsr" label="经手人" align="left" min-width="88" show-overflow-tooltip />
  118 + <el-table-column label="退款账户" prop="skzh" align="left" min-width="220" show-overflow-tooltip>
  119 + <template slot-scope="scope">{{ formatSkzhRow(scope.row) }}</template>
121 120 </el-table-column>
122   - <el-table-column prop="skje" label="退款金额" align="left" />
123   - <!-- <el-table-column prop="zdr" label="制单人" align="left" /> -->
124   - <el-table-column prop="shr" label="审核人" align="left" />
125   - <el-table-column prop="gzr" label="过账人" align="left" />
126   - <el-table-column prop="bz" label="备注" align="left" />
127   - <el-table-column prop="djlx" label="单据类型" align="left" />
128   - <el-table-column prop="djzt" label="审核状态" align="left">
  121 + <el-table-column prop="skje" label="退款金额" align="right" min-width="100" show-overflow-tooltip />
  122 + <el-table-column prop="shr" label="审核人" align="left" min-width="88" show-overflow-tooltip />
  123 + <el-table-column prop="gzr" label="过账人" align="left" min-width="88" show-overflow-tooltip />
  124 + <el-table-column prop="bz" label="备注" align="left" min-width="160" show-overflow-tooltip />
  125 + <el-table-column prop="djlx" label="单据类型" align="left" min-width="110" show-overflow-tooltip />
  126 + <el-table-column prop="djzt" label="审核状态" align="left" min-width="110" show-overflow-tooltip>
129 127 <template slot-scope="scope">
130 128 <el-tag v-if="scope.row.djzt === '已审核'" type="success">已审核</el-tag>
131 129 <el-tag v-else-if="scope.row.djzt === '一级已审'" type="">一级已审</el-tag>
... ... @@ -133,7 +131,7 @@
133 131 <el-tag v-else type="warning">{{ scope.row.djzt || '待审核' }}</el-tag>
134 132 </template>
135 133 </el-table-column>
136   - <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip class-name="cell-nowrap">
  134 + <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip>
137 135 <template slot-scope="scope">
138 136 <ncc-table-summary-cell :row="scope.row" fields="zy,Zy" />
139 137 </template>
... ... @@ -168,6 +166,7 @@
168 166 import { previewDataInterface } from '@/api/systemData/dataInterface'
169 167 import { promptApprovalRemark, postApproveGeneric, postRejectGeneric } from '@/utils/wtRejectApproval'
170 168 import { getAccountSelector } from '@/api/extend/wtAccount'
  169 + import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay'
171 170 export default {
172 171 components: { NCCForm, DetailView, XsckdDetailView, ExportBox },
173 172 data() {
... ... @@ -284,6 +283,9 @@
284 283 this.skzhOptions = res.data.list
285 284 });
286 285 },
  286 + formatSkzhRow(row) {
  287 + return formatWtSkzhDisplay(row, this.skzhOptions, { mode: 'refund' })
  288 + },
287 289 initData() {
288 290 this.listLoading = true;
289 291 let _query = {
... ... @@ -478,4 +480,9 @@
478 480 .ycddh-link {
479 481 font-weight: normal;
480 482 }
  483 +.wt-xsthd-table {
  484 + ::v-deep .el-table .cell {
  485 + white-space: nowrap;
  486 + }
  487 +}
481 488 </style>
482 489 \ No newline at end of file
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue
... ... @@ -1480,7 +1480,9 @@
1480 1480 row.dj = undefined;
1481 1481 row.je = undefined;
1482 1482 row.dqxsj = undefined;
  1483 + row.description = undefined;
1483 1484 this.$set(row, 'selectedSerialNumbers', []);
  1485 + this.$set(row, 'xlhList', []);
1484 1486 this.$set(row, 'productQuery', '');
1485 1487 row.loadingStock = false;
1486 1488 this.$set(row, 'spxlhLoaded', false);
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue
... ... @@ -1405,6 +1405,17 @@
1405 1405 return label.includes(query.toLowerCase());
1406 1406 },
1407 1407 handleProductChange(row) {
  1408 + if (!row.spbh) {
  1409 + this.$set(row, 'spmc', '')
  1410 + this.$set(row, 'sl', undefined)
  1411 + this.$set(row, 'dj', undefined)
  1412 + this.$set(row, 'je', undefined)
  1413 + this.$set(row, 'description', '')
  1414 + this.$set(row, 'kucun', undefined)
  1415 + this.$set(row, 'spxlhLoaded', false)
  1416 + this.$set(row, 'xlhList', [])
  1417 + return
  1418 + }
1408 1419 // 选中商品后可自动回填商品名称等信息
1409 1420 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1410 1421 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue
... ... @@ -647,6 +647,18 @@
647 647 this.updateTotalAmount();
648 648 },
649 649 async handleProductChange(row) {
  650 + if (!row.spbh) {
  651 + this.$set(row, 'spmc', '')
  652 + this.$set(row, 'sptm', '')
  653 + this.$set(row, 'dw', '')
  654 + this.$set(row, 'sl', undefined)
  655 + this.$set(row, 'dj', undefined)
  656 + this.$set(row, 'je', undefined)
  657 + this.$set(row, 'description', '')
  658 + this.$set(row, 'spxlhLoaded', false)
  659 + this.$set(row, 'xlhList', [])
  660 + return
  661 + }
650 662 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
651 663 console.log('选中商品', row.spbh, product);
652 664 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue
... ... @@ -1434,6 +1434,17 @@
1434 1434 return label.includes(query.toLowerCase());
1435 1435 },
1436 1436 handleProductChange(row) {
  1437 + if (!row.spbh) {
  1438 + this.$set(row, 'spmc', '')
  1439 + this.$set(row, 'sl', undefined)
  1440 + this.$set(row, 'dj', undefined)
  1441 + this.$set(row, 'je', undefined)
  1442 + this.$set(row, 'description', '')
  1443 + this.$set(row, 'kucun', undefined)
  1444 + this.$set(row, 'spxlhLoaded', false)
  1445 + this.$set(row, 'xlhList', [])
  1446 + return
  1447 + }
1437 1448 // 选中商品后可自动回填商品名称等信息
1438 1449 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
1439 1450 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue
... ... @@ -116,38 +116,38 @@
116 116 <screenfull isContainer />
117 117 </div>
118 118 </div>
119   - <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
120   - <el-table-column prop="id" label="单据编号" align="left" />
121   - <el-table-column prop="djrq" label="单据日期" align="left" :formatter="ncc.tableDateFormat" />
122   - <el-table-column label="出库仓库" prop="cjck" align="left">
  119 + <NCC-table v-loading="listLoading" class="wt-ysckd-table" :data="list" has-c @selection-change="handleSelectionChange">
  120 + <el-table-column prop="id" label="单据编号" align="left" min-width="150" show-overflow-tooltip />
  121 + <el-table-column prop="djrq" label="单据日期" align="left" min-width="110" :formatter="ncc.tableDateFormat" show-overflow-tooltip />
  122 + <el-table-column label="出库仓库" prop="cjck" align="left" min-width="120" show-overflow-tooltip>
123 123 <template slot-scope="scope">{{ scope.row.cjck | dynamicText(cjckOptions) }}</template>
124 124 </el-table-column>
125   - <el-table-column prop="jsr" label="经手人" align="left" />
126   - <el-table-column prop="hysjh" label="会员手机号码" align="left" />
127   - <el-table-column label="原价" align="left" min-width="90">
  125 + <el-table-column prop="jsr" label="经手人" align="left" min-width="88" show-overflow-tooltip />
  126 + <el-table-column prop="hysjh" label="会员手机号码" align="left" min-width="120" show-overflow-tooltip />
  127 + <el-table-column label="原价" align="right" min-width="96" show-overflow-tooltip>
128 128 <template slot-scope="scope">{{ getDisplayOriginalPrice(scope.row) }}</template>
129 129 </el-table-column>
130   - <el-table-column label="收款金额" align="left" min-width="90">
  130 + <el-table-column label="收款金额" align="right" min-width="96" show-overflow-tooltip>
131 131 <template slot-scope="scope">
132 132 {{ getDisplayCollectionAmount(scope.row) }}
133 133 </template>
134 134 </el-table-column>
135   - <el-table-column label="优惠金额" align="left" min-width="90">
  135 + <el-table-column label="优惠金额" align="right" min-width="96" show-overflow-tooltip>
136 136 <template slot-scope="scope">{{ getDisplayDiscountAmount(scope.row) }}</template>
137 137 </el-table-column>
138   - <el-table-column label="收款账户" prop="skzh" align="left">
  138 + <el-table-column label="收款账户" prop="skzh" align="left" min-width="220" show-overflow-tooltip>
139 139 <template slot-scope="scope">{{ formatSkzhRow(scope.row) }}</template>
140 140 </el-table-column>
141   - <el-table-column prop="zdr" label="制单人" align="left" />
142   - <el-table-column prop="shr" label="审核人" align="left" />
143   - <el-table-column prop="gzr" label="过账人" align="left" />
144   - <el-table-column prop="bz" label="备注" align="left" />
  141 + <el-table-column prop="zdr" label="制单人" align="left" min-width="88" show-overflow-tooltip />
  142 + <el-table-column prop="shr" label="审核人" align="left" min-width="88" show-overflow-tooltip />
  143 + <el-table-column prop="gzr" label="过账人" align="left" min-width="88" show-overflow-tooltip />
  144 + <el-table-column prop="bz" label="备注" align="left" min-width="160" show-overflow-tooltip />
145 145 <el-table-column prop="sy_pch" label="收银批次" align="left" min-width="120" show-overflow-tooltip />
146   - <el-table-column prop="djlx" label="单据类型" align="left" />
147   - <el-table-column label="单据来源" prop="ly" align="left" min-width="100">
  146 + <el-table-column prop="djlx" label="单据类型" align="left" min-width="110" show-overflow-tooltip />
  147 + <el-table-column label="单据来源" prop="ly" align="left" min-width="110" show-overflow-tooltip>
148 148 <template slot-scope="scope">{{ formatLy(scope.row.ly) }}</template>
149 149 </el-table-column>
150   - <el-table-column prop="djzt" label="审核状态" align="left">
  150 + <el-table-column prop="djzt" label="审核状态" align="left" min-width="110" show-overflow-tooltip>
151 151 <template slot-scope="scope">
152 152 <el-tag v-if="scope.row.djzt === '已审核'" type="success">已审核</el-tag>
153 153 <el-tag v-else-if="scope.row.djzt === '一级已审'" type="">一级已审</el-tag>
... ... @@ -157,7 +157,7 @@
157 157 <el-tag v-else type="warning">{{ scope.row.djzt || '待审核' }}</el-tag>
158 158 </template>
159 159 </el-table-column>
160   - <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip class-name="cell-nowrap">
  160 + <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip>
161 161 <template slot-scope="scope">
162 162 <ncc-table-summary-cell :row="scope.row" fields="zy,Zy" />
163 163 </template>
... ... @@ -743,4 +743,8 @@
743 743 .convert-button-wrapper .el-button {
744 744 pointer-events: auto !important;
745 745 }
  746 +
  747 + .wt-ysckd-table ::v-deep .el-table .cell {
  748 + white-space: nowrap;
  749 + }
746 750 </style>
747 751 \ No newline at end of file
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue
... ... @@ -603,6 +603,18 @@
603 603 this.updateTotalAmount();
604 604 },
605 605 async handleProductChange(row) {
  606 + if (!row.spbh) {
  607 + this.$set(row, 'spmc', '')
  608 + this.$set(row, 'sptm', '')
  609 + this.$set(row, 'dw', '')
  610 + this.$set(row, 'sl', undefined)
  611 + this.$set(row, 'dj', undefined)
  612 + this.$set(row, 'je', undefined)
  613 + this.$set(row, 'description', '')
  614 + this.$set(row, 'spxlhLoaded', false)
  615 + this.$set(row, 'xlhList', [])
  616 + return
  617 + }
606 618 const product = this.spbhOptions.find(item => item.F_Id === row.spbh);
607 619 console.log('选中商品', row.spbh, product);
608 620 if (product) {
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue
... ... @@ -162,7 +162,7 @@
162 162 <el-descriptions-item label="退款账户">
163 163 <span class="cell-nowrap">
164 164 <i class="el-icon-bank-card desc-icon desc-icon--success" />
165   - {{ labelFromOptions(detail.skzh, skzhOptions) }}
  165 + {{ displaySkzh() }}
166 166 </span>
167 167 </el-descriptions-item>
168 168 <el-descriptions-item label="审核人">
... ... @@ -205,6 +205,7 @@ import { previewDataInterface } from &#39;@/api/systemData/dataInterface&#39;
205 205 import { dynamicText } from '@/filters'
206 206 import YsckdDetailView from '../wtYsckd/detail-view'
207 207 import { getAccountSelector } from '@/api/extend/wtAccount'
  208 +import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay'
208 209  
209 210 export default {
210 211 name: 'WtYsthdDetailView',
... ... @@ -267,6 +268,10 @@ export default {
267 268 if (this.$refs.YsckdDetailView) this.$refs.YsckdDetailView.init(id)
268 269 })
269 270 },
  271 + displaySkzh() {
  272 + if (!this.detail) return '无'
  273 + return formatWtSkzhDisplay(this.detail, this.skzhOptions, { mode: 'refund' }) || '无'
  274 + },
270 275 labelFromOptions(value, options) {
271 276 if (value === null || value === undefined || value === '') return '无'
272 277 const opts = options || []
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue
... ... @@ -94,8 +94,8 @@
94 94 <screenfull isContainer />
95 95 </div>
96 96 </div>
97   - <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
98   - <el-table-column prop="id" label="单据编号" align="left" />
  97 + <NCC-table v-loading="listLoading" class="wt-ysthd-table" :data="list" has-c @selection-change="handleSelectionChange">
  98 + <el-table-column prop="id" label="单据编号" align="left" min-width="150" show-overflow-tooltip />
99 99 <el-table-column label="预售出库单号" align="left" min-width="150" show-overflow-tooltip>
100 100 <template slot-scope="scope">
101 101 <span class="cell-nowrap" @click.stop>
... ... @@ -110,29 +110,27 @@
110 110 </span>
111 111 </template>
112 112 </el-table-column>
113   - <el-table-column prop="djrq" label="单据日期" align="left" :formatter="ncc.tableDateFormat" />
114   - <el-table-column label="入库仓库" prop="cjck" align="left">
  113 + <el-table-column prop="djrq" label="单据日期" align="left" min-width="110" :formatter="ncc.tableDateFormat" show-overflow-tooltip />
  114 + <el-table-column label="入库仓库" prop="cjck" align="left" min-width="120" show-overflow-tooltip>
115 115 <template slot-scope="scope">{{ formatListCjck(scope.row) }}</template>
116 116 </el-table-column>
117   - <el-table-column prop="jsr" label="经手人" align="left" />
118   - <!-- <el-table-column prop="ysje" label="优惠金额" align="left" /> -->
119   - <el-table-column label="退款账户" prop="skzh" align="left">
120   - <template slot-scope="scope">{{ scope.row.skzh | dynamicText(skzhOptions) }}</template>
  117 + <el-table-column prop="jsr" label="经手人" align="left" min-width="88" show-overflow-tooltip />
  118 + <el-table-column label="退款账户" prop="skzh" align="left" min-width="220" show-overflow-tooltip>
  119 + <template slot-scope="scope">{{ formatSkzhRow(scope.row) }}</template>
121 120 </el-table-column>
122   - <el-table-column prop="skje" label="退款金额" align="left" />
123   - <!-- <el-table-column prop="zdr" label="制单人" align="left" /> -->
124   - <el-table-column prop="shr" label="审核人" align="left" />
125   - <el-table-column prop="gzr" label="过账人" align="left" />
126   - <el-table-column prop="bz" label="备注" align="left" />
127   - <el-table-column prop="djlx" label="单据类型" align="left" />
128   - <el-table-column prop="djzt" label="审核状态" align="left">
  121 + <el-table-column prop="skje" label="退款金额" align="right" min-width="100" show-overflow-tooltip />
  122 + <el-table-column prop="shr" label="审核人" align="left" min-width="88" show-overflow-tooltip />
  123 + <el-table-column prop="gzr" label="过账人" align="left" min-width="88" show-overflow-tooltip />
  124 + <el-table-column prop="bz" label="备注" align="left" min-width="160" show-overflow-tooltip />
  125 + <el-table-column prop="djlx" label="单据类型" align="left" min-width="110" show-overflow-tooltip />
  126 + <el-table-column prop="djzt" label="审核状态" align="left" min-width="110" show-overflow-tooltip>
129 127 <template slot-scope="scope">
130 128 <el-tag v-if="scope.row.djzt === '已审核'" type="success">已审核</el-tag>
131 129 <el-tag v-else-if="scope.row.djzt === '一级已审'" type="">一级已审</el-tag>
132 130 <el-tag v-else type="warning">{{ scope.row.djzt || '待审核' }}</el-tag>
133 131 </template>
134 132 </el-table-column>
135   - <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip class-name="cell-nowrap">
  133 + <el-table-column label="摘要" align="left" min-width="200" show-overflow-tooltip>
136 134 <template slot-scope="scope">
137 135 <ncc-table-summary-cell :row="scope.row" fields="zy,Zy" />
138 136 </template>
... ... @@ -166,6 +164,7 @@
166 164 import { previewDataInterface } from '@/api/systemData/dataInterface'
167 165 import { promptApprovalRemark, postApproveGeneric } from '@/utils/wtRejectApproval'
168 166 import { getAccountSelector } from '@/api/extend/wtAccount'
  167 + import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay'
169 168 export default {
170 169 components: { NCCForm, DetailView, YsckdDetailView, ExportBox },
171 170 data() {
... ... @@ -283,6 +282,9 @@
283 282 this.skzhOptions = res.data.list
284 283 });
285 284 },
  285 + formatSkzhRow(row) {
  286 + return formatWtSkzhDisplay(row, this.skzhOptions, { mode: 'refund' })
  287 + },
286 288 initData() {
287 289 this.listLoading = true;
288 290 let _query = {
... ... @@ -459,4 +461,9 @@
459 461 .ycddh-link {
460 462 font-weight: normal;
461 463 }
  464 +.wt-ysthd-table {
  465 + ::v-deep .el-table .cell {
  466 + white-space: nowrap;
  467 + }
  468 +}
462 469 </style>
463 470 \ No newline at end of file
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs
... ... @@ -73,6 +73,48 @@ public class OrdersController : ControllerBase
73 73 }
74 74  
75 75 /// <summary>
  76 + /// 拆分合并单:把这组订单从"同买家+同地址"的自动合并组中拆出来,分别单独发货。
  77 + /// 入参 orderIds 传当前合并行里的所有子订单 id(即列表返回的 mergedOrderIds)。
  78 + /// 已发货/已取消/已退款的订单会被忽略(只处理待发货的)。
  79 + /// </summary>
  80 + [HttpPost("split")]
  81 + public async Task<ActionResult> SplitMergedOrders([FromBody] SplitOrdersRequest request)
  82 + {
  83 + if (request?.OrderIds == null || request.OrderIds.Count == 0)
  84 + return BadRequest(new { message = "请传入要拆分的订单 id 列表(orderIds)" });
  85 + try
  86 + {
  87 + var updated = await _orderService.SplitMergedOrdersAsync(request.OrderIds);
  88 + return Ok(new { message = $"已拆分 {updated} 笔订单", updated });
  89 + }
  90 + catch (Exception ex)
  91 + {
  92 + _logger.LogError(ex, "拆分合并单失败: {Ids}", string.Join(",", request.OrderIds));
  93 + return StatusCode(500, new { message = "拆分合并单失败", error = ex.Message });
  94 + }
  95 + }
  96 +
  97 + /// <summary>
  98 + /// 恢复合并:取消"拆分"标记,下次列表会按同买家+同地址重新自动合并。
  99 + /// </summary>
  100 + [HttpPost("unsplit")]
  101 + public async Task<ActionResult> UnsplitOrders([FromBody] SplitOrdersRequest request)
  102 + {
  103 + if (request?.OrderIds == null || request.OrderIds.Count == 0)
  104 + return BadRequest(new { message = "请传入要恢复合并的订单 id 列表(orderIds)" });
  105 + try
  106 + {
  107 + var updated = await _orderService.UnsplitOrdersAsync(request.OrderIds);
  108 + return Ok(new { message = $"已恢复 {updated} 笔订单", updated });
  109 + }
  110 + catch (Exception ex)
  111 + {
  112 + _logger.LogError(ex, "恢复合并单失败: {Ids}", string.Join(",", request.OrderIds));
  113 + return StatusCode(500, new { message = "恢复合并单失败", error = ex.Message });
  114 + }
  115 + }
  116 +
  117 + /// <summary>
76 118 /// 诊断订单合并情况(排查为何未合并)
77 119 /// </summary>
78 120 [HttpGet("debug/merge")]
... ... @@ -233,6 +275,16 @@ public class OrdersController : ControllerBase
233 275 product_pic = itemObj["product_pic"]?.ToString()
234 276 ?? itemObj["ProductPic"]?.ToString()
235 277 ?? "",
  278 + // 抖音商品图(商品明细区展示);老发货单 JSON 里没有该字段,兜底用 product_pic
  279 + douyin_pic = itemObj["douyin_pic"]?.ToString()
  280 + ?? itemObj["DouyinPic"]?.ToString()
  281 + ?? itemObj["product_pic"]?.ToString()
  282 + ?? itemObj["ProductPic"]?.ToString()
  283 + ?? "",
  284 + // ERP 商品图(商品清单区展示);老发货单 JSON 里没有时为空,前端会在加载后按 spbm 再拉一次
  285 + erp_pic = itemObj["erp_pic"]?.ToString()
  286 + ?? itemObj["ErpPic"]?.ToString()
  287 + ?? "",
236 288 item_num = itemObj["item_num"]?.ToObject<int>()
237 289 ?? itemObj["ItemNum"]?.ToObject<int>()
238 290 ?? itemObj["itemNum"]?.ToObject<int>()
... ... @@ -266,7 +318,14 @@ public class OrdersController : ControllerBase
266 318 ?? false,
267 319 isFromErp = itemObj["isFromErp"]?.ToObject<bool>()
268 320 ?? itemObj["IsFromErp"]?.ToObject<bool>()
269   - ?? false
  321 + ?? false,
  322 + // 已选序列号:编辑发货单时需要回显,避免用户重新打开弹窗时历史选择丢失
  323 + selectedSerialNumbers = (itemObj["selectedSerialNumbers"]
  324 + ?? itemObj["SelectedSerialNumbers"]
  325 + ?? itemObj["serialNumbers"])
  326 + is Newtonsoft.Json.Linq.JArray snArr
  327 + ? snArr.Select(x => x?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
  328 + : new List<string>()
270 329 };
271 330  
272 331 // 记录ERP商品的调试信息
... ... @@ -323,7 +382,14 @@ public class OrdersController : ControllerBase
323 382 waybill = waybill != null ? new
324 383 {
325 384 cjck = waybill.Cjck,
326   - status = waybill.Status
  385 + status = waybill.Status,
  386 + // 已发货时前端要回显物流单号/物流公司
  387 + trackingNumber = waybill.TrackingNumber ?? "",
  388 + logisticsCompany = waybill.LogisticsCompany ?? "",
  389 + shipTime = waybill.ShipTime,
  390 + // 商家实际收入(元);null 表示还没改过,前端默认等于用户支付金额
  391 + merchantIncome = waybill.MerchantIncome,
  392 + fhr = waybill.Fhr ?? ""
327 393 } : null,
328 394 productItems = productItems
329 395 });
... ... @@ -383,6 +449,14 @@ public class OrdersController : ControllerBase
383 449 {
384 450 product_name = item["product_name"]?.ToString(),
385 451 product_pic = item["product_pic"]?.ToString(),
  452 + // 抖音图/ERP 图拆两个字段;老数据没有 douyin_pic 时兜底用 product_pic
  453 + douyin_pic = item["douyin_pic"]?.ToString()
  454 + ?? item["DouyinPic"]?.ToString()
  455 + ?? item["product_pic"]?.ToString()
  456 + ?? "",
  457 + erp_pic = item["erp_pic"]?.ToString()
  458 + ?? item["ErpPic"]?.ToString()
  459 + ?? "",
386 460 item_num = item["item_num"]?.ToObject<int>() ?? item["ItemNum"]?.ToObject<int>() ?? 1,
387 461 goods_price = item["goods_price"]?.ToObject<long>() ?? item["GoodsPrice"]?.ToObject<long>() ?? 0,
388 462 spec = item["spec"],
... ... @@ -398,6 +472,21 @@ public class OrdersController : ControllerBase
398 472 var orderIdsStr = string.Join(",", orders.Select(o => o.OrderId));
399 473 var allBuyerWords = string.Join("\n", orders.Where(o => !string.IsNullOrEmpty(o.BuyerWords)).Select(o => o.BuyerWords));
400 474 var allSellerWords = string.Join("\n", orders.Where(o => !string.IsNullOrEmpty(o.SellerWords)).Select(o => o.SellerWords));
  475 +
  476 + // 合并订单也可能已经存在发货单(任意一条子订单的 id 都可能被当作 waybill.OrderId),编辑时需要回显已保存的商家实际收入/物流单号等
  477 + Waybill? mergedWaybill = null;
  478 + try
  479 + {
  480 + mergedWaybill = await db.Queryable<Waybill>()
  481 + .Where(w => idList.Contains(w.OrderId))
  482 + .OrderByDescending(w => w.CreateTime)
  483 + .FirstAsync();
  484 + }
  485 + catch (Exception exw)
  486 + {
  487 + _logger.LogWarning(exw, "查询合并订单发货单失败: ids={Ids}", ids);
  488 + }
  489 +
401 490 return Ok(new
402 491 {
403 492 order = new
... ... @@ -420,7 +509,16 @@ public class OrdersController : ControllerBase
420 509 buyerWords = allBuyerWords,
421 510 sellerWords = allSellerWords
422 511 },
423   - waybill = (object?)null,
  512 + waybill = mergedWaybill != null ? (object)new
  513 + {
  514 + cjck = mergedWaybill.Cjck,
  515 + status = mergedWaybill.Status,
  516 + trackingNumber = mergedWaybill.TrackingNumber ?? "",
  517 + logisticsCompany = mergedWaybill.LogisticsCompany ?? "",
  518 + shipTime = mergedWaybill.ShipTime,
  519 + merchantIncome = mergedWaybill.MerchantIncome,
  520 + fhr = mergedWaybill.Fhr ?? ""
  521 + } : null,
424 522 productItems
425 523 });
426 524 }
... ... @@ -461,6 +559,10 @@ public class OrdersController : ControllerBase
461 559 {
462 560 try
463 561 {
  562 + // 每次同步前先从 ERP 刷新一次店铺配置,保证 ERP 里的改动无需重启抖音服务即可生效
  563 + try { await _douyinFactory.ReloadAsync(); }
  564 + catch (Exception reloadEx) { _logger.LogWarning(reloadEx, "同步前刷新店铺配置失败,继续使用当前已加载的配置"); }
  565 +
464 566 await _orderService.SyncOrdersAsync(shopId);
465 567 return Ok(new { message = "订单同步成功" });
466 568 }
... ... @@ -1140,17 +1242,23 @@ public class OrdersController : ControllerBase
1140 1242 {
1141 1243 if (item is Newtonsoft.Json.Linq.JObject itemObj)
1142 1244 {
1143   - // 将productCode字段更新为转换后的商品ID(F_Id)
1144 1245 itemObj["productCode"] = actualProductCode;
1145   - _logger.LogInformation("更新序列号数据中的productCode: {OriginalProductCode} -> {ActualProductCode}",
1146   - productCode, actualProductCode);
1147 1246 }
1148 1247 }
1149 1248 }
1150   -
1151   - return Ok(new { code = 200, serialNumbers = serialNumbers });
  1249 +
  1250 + // 注意:这里不能用 Ok(new { code = 200, serialNumbers = serialNumbers })
  1251 + // ASP.NET Core 默认用 System.Text.Json,会把 Newtonsoft 的 JObject 当作
  1252 + // IEnumerable<JProperty> 递归序列化,结果变成 [[[]],[[]],...] 多层嵌套空数组。
  1253 + // 改用 Newtonsoft.Json 直接序列化后原样写回,保证对象字段完整。
  1254 + var payload = JsonConvert.SerializeObject(new
  1255 + {
  1256 + code = 200,
  1257 + serialNumbers = serialNumbersArray ?? new Newtonsoft.Json.Linq.JArray()
  1258 + });
  1259 + return Content(payload, "application/json; charset=utf-8");
1152 1260 }
1153   -
  1261 +
1154 1262 // 如果没有找到序列号,返回空列表
1155 1263 return Ok(new { code = 200, serialNumbers = new List<object>() });
1156 1264 }
... ... @@ -1292,6 +1400,7 @@ public class OrdersController : ControllerBase
1292 1400 shopName = data["shopName"]?.ToString() ?? "",
1293 1401 kh = data["kh"]?.ToString() ?? "",
1294 1402 skzh = data["skzh"]?.ToString() ?? "",
  1403 + ck = data["ck"]?.ToString() ?? "",
1295 1404 bz = data["bz"]?.ToString() ?? ""
1296 1405 }
1297 1406 });
... ... @@ -1532,7 +1641,7 @@ public class OrdersController : ControllerBase
1532 1641 }
1533 1642  
1534 1643 /// <summary>
1535   - /// 获取收款账户列表(代理 ERP WtSkzhb 接口
  1644 + /// 获取收款账户列表(代理 ERP WtAccount 接口,wt_account 是账户主表
1536 1645 /// </summary>
1537 1646 [HttpGet("payment-accounts")]
1538 1647 public async Task<ActionResult> GetPaymentAccounts()
... ... @@ -1543,7 +1652,7 @@ public class OrdersController : ControllerBase
1543 1652 var httpClientFactory = HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
1544 1653 var httpClient = httpClientFactory.CreateClient();
1545 1654  
1546   - var apiUrl = $"{erpApiConfig.BaseUrl}/api/Extend/WtSkzhb?currentPage=1&pageSize=1000";
  1655 + var apiUrl = $"{erpApiConfig.BaseUrl}/api/Extend/WtAccount?currentPage=1&pageSize=1000&status=1";
1547 1656 var request = await _orderService.CreateAuthenticatedRequestAsync(System.Net.Http.HttpMethod.Get, apiUrl);
1548 1657 var response = await httpClient.SendAsync(request);
1549 1658 if (!response.IsSuccessStatusCode)
... ... @@ -1558,8 +1667,17 @@ public class OrdersController : ControllerBase
1558 1667 if (result?["data"] is JObject dataObj)
1559 1668 {
1560 1669 var list = dataObj["list"] as JArray ?? new JArray();
1561   - var accountList = list.Select(item => item is JObject o ? o.ToObject<Dictionary<string, object>>() : null)
1562   - .Where(x => x != null).ToList();
  1670 + var accountList = list.Select(item =>
  1671 + {
  1672 + if (item is not JObject o) return null;
  1673 + return (object)new
  1674 + {
  1675 + id = o["id"]?.ToString() ?? "",
  1676 + accountName = o["accountName"]?.ToString() ?? "",
  1677 + accountCode = o["accountCode"]?.ToString() ?? "",
  1678 + category = o["category"]?.ToString() ?? ""
  1679 + };
  1680 + }).Where(x => x != null).ToList();
1563 1681 return Ok(new { code = 200, data = accountList });
1564 1682 }
1565 1683 return Ok(new { code = 200, data = new List<object>() });
... ... @@ -1572,6 +1690,62 @@ public class OrdersController : ControllerBase
1572 1690 }
1573 1691  
1574 1692 /// <summary>
  1693 + /// 获取 ERP 用户列表(用于发货单中"发货人"下拉),代理 ERP /api/permission/Users/All
  1694 + /// </summary>
  1695 + [HttpGet("users")]
  1696 + public async Task<ActionResult> GetUsers()
  1697 + {
  1698 + try
  1699 + {
  1700 + var erpApiConfig = HttpContext.RequestServices.GetRequiredService<ErpApiConfig>();
  1701 + var httpClientFactory = HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
  1702 + var httpClient = httpClientFactory.CreateClient();
  1703 +
  1704 + var apiUrl = $"{erpApiConfig.BaseUrl}/api/permission/Users/All";
  1705 + var request = await _orderService.CreateAuthenticatedRequestAsync(System.Net.Http.HttpMethod.Get, apiUrl);
  1706 + var response = await httpClient.SendAsync(request);
  1707 + if (!response.IsSuccessStatusCode)
  1708 + {
  1709 + var errBody = await response.Content.ReadAsStringAsync();
  1710 + _logger.LogWarning("ERP 用户列表接口返回 {StatusCode}: {Body}", response.StatusCode, errBody);
  1711 + return StatusCode(500, new { message = "查询用户列表失败" });
  1712 + }
  1713 +
  1714 + var content = await response.Content.ReadAsStringAsync();
  1715 + var result = JsonConvert.DeserializeObject<JObject>(content);
  1716 + // NCC 框架返回 { code, msg, data: [...] },其中 data 可能是数组或 { list: [...] }
  1717 + JArray list = null;
  1718 + var dataToken = result?["data"];
  1719 + if (dataToken is JArray arr) list = arr;
  1720 + else if (dataToken is JObject obj && obj["list"] is JArray subArr) list = subArr;
  1721 + list ??= new JArray();
  1722 +
  1723 + var userList = list.Select(item =>
  1724 + {
  1725 + if (item is not JObject o) return null;
  1726 + // ERP 用户 Id 字段:兼容 id / F_Id
  1727 + var id = o["id"]?.ToString() ?? o["F_Id"]?.ToString() ?? o["userId"]?.ToString() ?? "";
  1728 + var realName = o["realName"]?.ToString() ?? o["F_RealName"]?.ToString() ?? "";
  1729 + var account = o["account"]?.ToString() ?? o["F_Account"]?.ToString() ?? "";
  1730 + if (string.IsNullOrEmpty(id)) return null;
  1731 + return (object)new
  1732 + {
  1733 + id,
  1734 + realName,
  1735 + account,
  1736 + displayName = !string.IsNullOrEmpty(realName) ? realName : (account ?? id)
  1737 + };
  1738 + }).Where(x => x != null).ToList();
  1739 + return Ok(new { code = 200, data = userList });
  1740 + }
  1741 + catch (Exception ex)
  1742 + {
  1743 + _logger.LogError(ex, "查询用户列表失败");
  1744 + return StatusCode(500, new { message = "查询用户列表失败", error = ex.Message });
  1745 + }
  1746 + }
  1747 +
  1748 + /// <summary>
1575 1749 /// 调试接口:查看发货单的原始ProductInfo数据
1576 1750 /// </summary>
1577 1751 [HttpGet("debug/waybill-productinfo/{orderId}")]
... ... @@ -1952,6 +2126,35 @@ public class OrdersController : ControllerBase
1952 2126 });
1953 2127 return Ok(new { data = shops });
1954 2128 }
  2129 +
  2130 + /// <summary>
  2131 + /// 从 ERP(WtDyDpsz/internal/all)重新拉取抖音店铺配置并热替换。
  2132 + /// 在 ERP「抖音店铺设置」中新增/修改/停用店铺后调用,无需重启抖音服务。
  2133 + /// </summary>
  2134 + [HttpPost("/api/shop-configs/reload")]
  2135 + public async Task<ActionResult> ReloadShopConfigs()
  2136 + {
  2137 + try
  2138 + {
  2139 + var count = await _douyinFactory.ReloadAsync();
  2140 + var shops = _douyinFactory.GetAllConfigs().Select(c => new
  2141 + {
  2142 + shopId = c.ShopId,
  2143 + shopName = c.ShopName
  2144 + });
  2145 + return Ok(new
  2146 + {
  2147 + message = $"已从 ERP 加载 {count} 个抖音店铺",
  2148 + count,
  2149 + data = shops
  2150 + });
  2151 + }
  2152 + catch (Exception ex)
  2153 + {
  2154 + _logger.LogError(ex, "刷新抖音店铺配置失败");
  2155 + return StatusCode(500, new { message = "刷新抖音店铺配置失败", error = ex.Message });
  2156 + }
  2157 + }
1955 2158 }
1956 2159  
1957 2160 public class UpdateSellerRemarkRequest
... ... @@ -1959,6 +2162,14 @@ public class UpdateSellerRemarkRequest
1959 2162 public string? SellerWords { get; set; }
1960 2163 }
1961 2164  
  2165 +/// <summary>
  2166 +/// 拆分 / 恢复合并单入参:orderIds 一般来自列表行的 mergedOrderIds。
  2167 +/// </summary>
  2168 +public class SplitOrdersRequest
  2169 +{
  2170 + public List<int> OrderIds { get; set; } = new List<int>();
  2171 +}
  2172 +
1962 2173 public class ManualShipRequest
1963 2174 {
1964 2175 /// <summary>运单号</summary>
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs
... ... @@ -180,6 +180,12 @@ public class WaybillController : ControllerBase
180 180 return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" });
181 181 }
182 182  
  183 + // 发货人必填(写入 ERP 销售出库单 fhr)
  184 + if (string.IsNullOrWhiteSpace(request.Fhr))
  185 + {
  186 + return BadRequest(new { message = "请先选择发货人" });
  187 + }
  188 +
183 189 var db = HttpContext.RequestServices.GetRequiredService<ISqlSugarClient>();
184 190  
185 191 // 检查是否已存在发货单(按订单ID查找最新的发货单)
... ... @@ -259,13 +265,30 @@ public class WaybillController : ControllerBase
259 265 _logger.LogWarning("更新发货单商品信息 - ProductItems为空或数量为0: OrderId={OrderId}", request.OrderId);
260 266 }
261 267 existingWaybill.Remark = request.Remark ?? existingWaybill.Remark;
  268 + // 商家实际收入:前端每次提交都会带最新值;为空则保留原值
  269 + if (request.MerchantIncome.HasValue)
  270 + existingWaybill.MerchantIncome = request.MerchantIncome.Value;
  271 + // 发货人:已在入口处校验非空
  272 + existingWaybill.Fhr = request.Fhr;
262 273 existingWaybill.UpdateTime = DateTime.Now;
263 274  
264   - // 商品明细变更时清除已关联的销售出库单,让后续流程重新创建
  275 + // 发货单被重新编辑:先到 ERP 删掉旧的销售出库单,再清除本地关联,让后续流程生成新单
265 276 if (!string.IsNullOrEmpty(existingWaybill.SalesOrderId))
266 277 {
267   - _logger.LogInformation("发货单商品明细已更新,清除旧销售出库单关联: WaybillId={WaybillId}, OldSalesOrderId={OldSalesOrderId}",
268   - existingWaybill.Id, existingWaybill.SalesOrderId);
  278 + var oldSalesOrderId = existingWaybill.SalesOrderId;
  279 + _logger.LogInformation("发货单被重新编辑,准备删除 ERP 旧销售出库单: WaybillId={WaybillId}, OldSalesOrderId={OldSalesOrderId}",
  280 + existingWaybill.Id, oldSalesOrderId);
  281 +
  282 + var deleted = await _orderService.DeleteErpSalesOrderAsync(oldSalesOrderId);
  283 + if (!deleted)
  284 + {
  285 + // 旧单删不掉(通常是已被审核或已关联退货单),不能继续创建新单,否则 ERP 会出现重复出库
  286 + return BadRequest(new
  287 + {
  288 + message = $"无法更新发货单:旧的销售出库单({oldSalesOrderId})在 ERP 中删除失败,可能已被审核或已关联退货单,请先到 ERP 处理后再试。"
  289 + });
  290 + }
  291 +
269 292 existingWaybill.SalesOrderId = null;
270 293 }
271 294  
... ... @@ -273,6 +296,7 @@ public class WaybillController : ControllerBase
273 296 waybill = existingWaybill;
274 297 waybillId = existingWaybill.Id;
275 298 isUpdate = true;
  299 + DouyinLogistics.API.Services.OrderService.InvalidateOrderListCache();
276 300  
277 301 _logger.LogInformation("更新发货单成功: WaybillId={WaybillId}, OrderId={OrderId}", waybillId, request.OrderId);
278 302 }
... ... @@ -300,6 +324,8 @@ public class WaybillController : ControllerBase
300 324 Cjck = request.Cjck,
301 325 ProductInfo = "",
302 326 Remark = request.Remark,
  327 + MerchantIncome = request.MerchantIncome,
  328 + Fhr = request.Fhr,
303 329 CreateTime = DateTime.Now,
304 330 UpdateTime = DateTime.Now
305 331 };
... ... @@ -337,6 +363,7 @@ public class WaybillController : ControllerBase
337 363 }
338 364  
339 365 waybillId = await db.Insertable(waybill).ExecuteReturnIdentityAsync();
  366 + DouyinLogistics.API.Services.OrderService.InvalidateOrderListCache();
340 367 _logger.LogInformation("手动创建发货单成功: WaybillId={WaybillId}, OrderId={OrderId}", waybillId, request.OrderId);
341 368 }
342 369  
... ... @@ -349,7 +376,8 @@ public class WaybillController : ControllerBase
349 376 request.OrderId, request.DouyinOrderIds);
350 377 var salesOrderResult = await _orderService.CreateSalesOrderAsync(
351 378 request.OrderId, request.Kh, request.Skzh, request.Djlx,
352   - request.DouyinOrderIds, request.MergedOrderInternalIds);
  379 + request.DouyinOrderIds, request.MergedOrderInternalIds,
  380 + request.MerchantIncome, request.Fhr);
353 381  
354 382 if (salesOrderResult.Success)
355 383 {
... ... @@ -816,5 +844,13 @@ public class ManualCreateWaybillRequest
816 844 public List<int>? MergedOrderInternalIds { get; set; }
817 845 public List<object>? ProductItems { get; set; }
818 846 public string? Remark { get; set; }
  847 + /// <summary>
  848 + /// 商家实际收入(元)。前端可修改,默认 = 用户支付金额;最终写入 ERP 销售出库单的 skje。
  849 + /// </summary>
  850 + public decimal? MerchantIncome { get; set; }
  851 + /// <summary>
  852 + /// 发货人(ERP 用户 Id),必填。写入 ERP 销售出库单的 fhr 字段。
  853 + /// </summary>
  854 + public string? Fhr { get; set; }
819 855 }
820 856  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs
... ... @@ -195,6 +195,12 @@ public class Order
195 195 public string? OpenId { get; set; }
196 196  
197 197 /// <summary>
  198 + /// 是否已从合并组中拆出单独发货。true 表示用户手动拆分,列表不再按同买家+同地址合并此单。
  199 + /// </summary>
  200 + [SugarColumn(ColumnDataType = "tinyint", IsNullable = false, DefaultValue = "0")]
  201 + public bool NoMerge { get; set; }
  202 +
  203 + /// <summary>
198 204 /// 合并的订单ID列表(仅列表接口返回,不持久化。用于同人同地址合并时标识包含的订单)
199 205 /// </summary>
200 206 [SugarColumn(IsIgnore = true)]
... ... @@ -229,5 +235,11 @@ public class Order
229 235 /// </summary>
230 236 [SugarColumn(IsIgnore = true)]
231 237 public bool IsPendingShipmentForm { get; set; }
  238 +
  239 + /// <summary>
  240 + /// 是否可以「恢复合并」:NoMerge=true 且同买家+同地址下仍存在其他待发货订单时为 true(仅列表接口返回,不持久化)。
  241 + /// </summary>
  242 + [SugarColumn(IsIgnore = true)]
  243 + public bool CanUnsplit { get; set; }
232 244 }
233 245  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs
... ... @@ -151,5 +151,18 @@ public class Waybill
151 151 /// </summary>
152 152 [SugarColumn(Length = 100, IsNullable = true)]
153 153 public string? SalesOrderId { get; set; }
  154 +
  155 + /// <summary>
  156 + /// 商家实际收入(元,可选)。用户可在编辑发货单时修改;空值表示按默认 = 用户支付金额。
  157 + /// 提交 ERP 时会写入销售出库单的 skje(收款金额)。
  158 + /// </summary>
  159 + [SugarColumn(ColumnDataType = "decimal(12,2)", IsNullable = true)]
  160 + public decimal? MerchantIncome { get; set; }
  161 +
  162 + /// <summary>
  163 + /// 发货人(ERP 用户 Id)。提交发货单时必填,会写入 ERP 销售出库单的 fhr 字段。
  164 + /// </summary>
  165 + [SugarColumn(Length = 64, IsNullable = true)]
  166 + public string? Fhr { get; set; }
154 167 }
155 168  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs
... ... @@ -28,9 +28,13 @@ builder.Services.AddCors(options =&gt;
28 28 // 配置 HttpClient
29 29 builder.Services.AddHttpClient();
30 30  
31   -// 配置抖音服务(支持多店铺:DouyinShops 数组)
32   -var douyinShopConfigs = builder.Configuration.GetSection("DouyinShops").Get<List<DouyinConfig>>() ?? new List<DouyinConfig>();
33   -if (douyinShopConfigs.Count == 0)
  31 +// 内存缓存:用于订单列表分页的短期缓存,大幅降低翻页时的重复计算
  32 +builder.Services.AddMemoryCache();
  33 +
  34 +// 抖音店铺配置:首选来源 = ERP(WtDyDpsz/internal/all),兜底 = appsettings.json/DouyinShops
  35 +// 这样后续新开店铺时,只需要在 ERP 页面「抖音店铺设置」里维护,无需改配置文件和重启。
  36 +var douyinShopFallback = builder.Configuration.GetSection("DouyinShops").Get<List<DouyinConfig>>() ?? new List<DouyinConfig>();
  37 +if (douyinShopFallback.Count == 0)
34 38 {
35 39 // 兼容旧版单店配置
36 40 var douyinSection = builder.Configuration.GetSection("Douyin");
... ... @@ -38,7 +42,7 @@ if (douyinShopConfigs.Count == 0)
38 42 {
39 43 long envShopId = 0;
40 44 long.TryParse(Environment.GetEnvironmentVariable("DY_SHOP_ID"), out envShopId);
41   - douyinShopConfigs.Add(new DouyinConfig
  45 + douyinShopFallback.Add(new DouyinConfig
42 46 {
43 47 SyncDays = douyinSection.GetValue<int?>("SyncDays") ?? 30,
44 48 AppKey = Environment.GetEnvironmentVariable("DY_APP_KEY") ?? douyinSection["AppKey"] ?? string.Empty,
... ... @@ -57,12 +61,27 @@ if (douyinShopConfigs.Count == 0)
57 61 });
58 62 }
59 63 }
  64 +
  65 +// ShopConfigProvider:封装从 ERP 拉取店铺配置的逻辑(ERP 不可用时自动回退到 DouyinShops)
  66 +builder.Services.AddSingleton<ShopConfigProvider>(sp =>
  67 +{
  68 + var httpFactory = sp.GetRequiredService<IHttpClientFactory>();
  69 + var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
  70 + var erpCfg = sp.GetRequiredService<ErpApiConfig>();
  71 + return new ShopConfigProvider(httpFactory, loggerFactory.CreateLogger<ShopConfigProvider>(), erpCfg, douyinShopFallback);
  72 +});
  73 +
60 74 // 注册多店 Factory(Singleton,每个店一个 DouyinService 实例)
  75 +// 构造函数里不立即加载,由启动钩子和 reload 接口驱动
61 76 builder.Services.AddSingleton<DouyinServiceFactory>(sp =>
62 77 {
63 78 var httpFactory = sp.GetRequiredService<IHttpClientFactory>();
64 79 var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
65   - return new DouyinServiceFactory(douyinShopConfigs, httpFactory, loggerFactory);
  80 + var provider = sp.GetRequiredService<ShopConfigProvider>();
  81 + var factory = new DouyinServiceFactory(httpFactory, loggerFactory, provider);
  82 + // 先用兜底配置填充一份,避免极早请求到达时 factory 为空
  83 + factory.ReplaceAll(douyinShopFallback);
  84 + return factory;
66 85 });
67 86  
68 87 // 配置顺丰服务
... ... @@ -185,7 +204,8 @@ _ = Task.Run(async () =&gt;
185 204 { "ProductItems", "text NULL COMMENT '商品明细(JSON格式,包含所有商品信息)'" },
186 205 { "BuyerWords", "varchar(500) NULL COMMENT '买家留言'" },
187 206 { "SellerWords", "varchar(500) NULL COMMENT '卖家备注'" },
188   - { "LogisticsCode", "varchar(50) NULL COMMENT '物流公司代码(抖音company_code)'" }
  207 + { "LogisticsCode", "varchar(50) NULL COMMENT '物流公司代码(抖音company_code)'" },
  208 + { "NoMerge", "tinyint NOT NULL DEFAULT 0 COMMENT '是否手动拆分出合并组:1=不参与自动合并'" }
189 209 };
190 210  
191 211 foreach (var field in fieldsToAdd)
... ... @@ -228,7 +248,9 @@ _ = Task.Run(async () =&gt;
228 248 {
229 249 { "ShopId", "bigint NULL COMMENT '所属店铺ID(抖音ShopId)'" },
230 250 { "Cjck", "varchar(100) NULL COMMENT '出库仓库(门店ID)'" },
231   - { "SalesOrderId", "varchar(100) NULL COMMENT '销售出库单ID(ERP系统中的出库单ID)'" }
  251 + { "SalesOrderId", "varchar(100) NULL COMMENT '销售出库单ID(ERP系统中的出库单ID)'" },
  252 + { "MerchantIncome", "decimal(12,2) NULL COMMENT '商家实际收入(元)。空值表示按用户支付金额默认'" },
  253 + { "Fhr", "varchar(64) NULL COMMENT '发货人(ERP 用户 Id),提交销售出库单时写入 ERP fhr 字段'" }
232 254 };
233 255  
234 256 foreach (var field in fieldsToAdd)
... ... @@ -300,9 +322,11 @@ CREATE TABLE IF NOT EXISTS `waybills` (
300 322 }
301 323  
302 324 // 历史数据刷 ShopId:把 ShopId 为空的订单/运单归属到第一个配置的店铺
303   - if (douyinShopConfigs.Count > 0)
  325 + var factoryForHistory = app.Services.GetRequiredService<DouyinServiceFactory>();
  326 + var historyCfgs = factoryForHistory.GetAllConfigs();
  327 + if (historyCfgs.Count > 0)
304 328 {
305   - var defaultCfg = douyinShopConfigs[0];
  329 + var defaultCfg = historyCfgs[0];
306 330 var updOrders = db.Ado.ExecuteCommand(
307 331 "UPDATE `orders` SET ShopId = @sid, ShopName = @sname WHERE ShopId IS NULL",
308 332 new { sid = defaultCfg.ShopId, sname = defaultCfg.ShopName });
... ... @@ -321,4 +345,20 @@ CREATE TABLE IF NOT EXISTS `waybills` (
321 345 }
322 346 });
323 347  
  348 +// 启动时异步从 ERP 拉取抖音店铺配置并热替换 Factory(ERP 不可用时已经有 appsettings 兜底)
  349 +_ = Task.Run(async () =>
  350 +{
  351 + await Task.Delay(1500);
  352 + try
  353 + {
  354 + var factory = app.Services.GetRequiredService<DouyinServiceFactory>();
  355 + var count = await factory.ReloadAsync();
  356 + Console.WriteLine($"✅ 启动时已从 ERP 加载 {count} 个抖音店铺配置");
  357 + }
  358 + catch (Exception ex)
  359 + {
  360 + Console.WriteLine($"⚠️ 启动时从 ERP 加载抖音店铺配置失败(已使用 appsettings 兜底): {ex.Message}");
  361 + }
  362 +});
  363 +
324 364 app.Run();
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs
... ... @@ -24,6 +24,23 @@ public class DouyinService
24 24 /// <summary>接口未返回 expires_in 时采用的默认剩余秒数(约 2 小时)。</summary>
25 25 private const int DefaultExpiresInWhenUnknownSeconds = 7200;
26 26  
  27 + /// <summary>北京时区(UTC+8)。用于把抖音返回的 Unix 时间戳转换为业务所需的北京时间。</summary>
  28 + private static readonly TimeZoneInfo BeijingTimeZone = ResolveBeijingTimeZone();
  29 +
  30 + private static TimeZoneInfo ResolveBeijingTimeZone()
  31 + {
  32 + // .NET 6+ 在 Windows/Linux/macOS 均支持 "Asia/Shanghai";异常时兜底 Windows 标识
  33 + try { return TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai"); }
  34 + catch { return TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"); }
  35 + }
  36 +
  37 + /// <summary>把 Unix 秒时间戳转换为北京时间(Kind=Unspecified,便于直接写入 MySQL DATETIME)</summary>
  38 + private static DateTime UnixSecondsToBeijingTime(long seconds)
  39 + {
  40 + var utc = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
  41 + return TimeZoneInfo.ConvertTimeFromUtc(utc, BeijingTimeZone);
  42 + }
  43 +
27 44 public DouyinService(DouyinConfig config, IHttpClientFactory httpClientFactory, ILogger<DouyinService> logger)
28 45 {
29 46 _config = config;
... ... @@ -483,22 +500,22 @@ public class DouyinService
483 500 }
484 501 }
485 502  
486   - // 抖音下单时间(create_time,秒或毫秒)
  503 + // 抖音下单时间(create_time,秒或毫秒)→ 转北京时间
487 504 var createTimeObj = orderObj["create_time"];
488 505 if (createTimeObj != null && long.TryParse(createTimeObj.ToString(), out var createTimeRaw))
489 506 {
490 507 var sec = createTimeRaw > 1_000_000_000_000L ? createTimeRaw / 1000 : createTimeRaw;
491 508 if (sec > 0)
492   - order.DouyinOrderTime = DateTimeOffset.FromUnixTimeSeconds(sec).DateTime;
  509 + order.DouyinOrderTime = UnixSecondsToBeijingTime(sec);
493 510 }
494 511  
495   - // 提取付款时间(秒或毫秒)
  512 + // 提取付款时间(秒或毫秒)→ 转北京时间
496 513 var payTimeObj = orderObj["pay_time"];
497 514 if (payTimeObj != null && long.TryParse(payTimeObj.ToString(), out var payTimeRaw))
498 515 {
499 516 var paySec = payTimeRaw > 1_000_000_000_000L ? payTimeRaw / 1000 : payTimeRaw;
500 517 if (paySec > 0)
501   - order.PayTime = DateTimeOffset.FromUnixTimeSeconds(paySec).DateTime;
  518 + order.PayTime = UnixSecondsToBeijingTime(paySec);
502 519 }
503 520  
504 521 // 提取订单金额和实付金额
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs
... ... @@ -5,21 +5,75 @@ namespace DouyinLogistics.API.Services;
5 5  
6 6 /// <summary>
7 7 /// 管理多个抖店的 DouyinService 实例(每个 ShopId 一个),Singleton 注册。
  8 +/// 配置来源:启动时由 Program.cs 通过 ShopConfigProvider 从 ERP 拉取,
  9 +/// 运行时可以通过 ReloadAsync 重新拉取、热替换。
8 10 /// </summary>
9 11 public class DouyinServiceFactory
10 12 {
11 13 private readonly ConcurrentDictionary<long, DouyinService> _services = new();
12   - private readonly List<DouyinConfig> _configs;
  14 + private List<DouyinConfig> _configs;
  15 + private readonly IHttpClientFactory _httpClientFactory;
  16 + private readonly ILoggerFactory _loggerFactory;
  17 + private readonly ShopConfigProvider _shopConfigProvider;
  18 + private readonly ILogger<DouyinServiceFactory> _logger;
  19 + private readonly SemaphoreSlim _reloadLock = new(1, 1);
13 20  
14   - public DouyinServiceFactory(IEnumerable<DouyinConfig> configs, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
  21 + public DouyinServiceFactory(
  22 + IHttpClientFactory httpClientFactory,
  23 + ILoggerFactory loggerFactory,
  24 + ShopConfigProvider shopConfigProvider)
15 25 {
16   - _configs = configs.ToList();
17   - foreach (var cfg in _configs)
  26 + _httpClientFactory = httpClientFactory;
  27 + _loggerFactory = loggerFactory;
  28 + _shopConfigProvider = shopConfigProvider;
  29 + _logger = loggerFactory.CreateLogger<DouyinServiceFactory>();
  30 + _configs = new List<DouyinConfig>();
  31 + }
  32 +
  33 + /// <summary>
  34 + /// 使用指定配置列表初始化/重建所有 DouyinService 实例。
  35 + /// 现有实例会被替换,实现热更新。
  36 + /// </summary>
  37 + public void ReplaceAll(IEnumerable<DouyinConfig> configs)
  38 + {
  39 + var list = configs?.ToList() ?? new List<DouyinConfig>();
  40 + _configs = list;
  41 + var newIds = new HashSet<long>(list.Select(c => c.ShopId));
  42 +
  43 + foreach (var cfg in list)
18 44 {
19   - var logger = loggerFactory.CreateLogger<DouyinService>();
20   - var svc = new DouyinService(cfg, httpClientFactory, logger);
  45 + if (cfg.ShopId <= 0) continue;
  46 + var svc = new DouyinService(cfg, _httpClientFactory, _loggerFactory.CreateLogger<DouyinService>());
21 47 _services[cfg.ShopId] = svc;
22 48 }
  49 +
  50 + foreach (var key in _services.Keys.ToList())
  51 + {
  52 + if (!newIds.Contains(key))
  53 + {
  54 + _services.TryRemove(key, out _);
  55 + }
  56 + }
  57 + _logger.LogInformation("✅ DouyinServiceFactory 已加载 {Count} 个店铺:{Ids}", _services.Count,
  58 + string.Join(", ", _services.Keys));
  59 + }
  60 +
  61 + /// <summary>
  62 + /// 从 ERP 刷新店铺配置并热替换服务实例。
  63 + /// </summary>
  64 + public async Task<int> ReloadAsync(CancellationToken ct = default)
  65 + {
  66 + await _reloadLock.WaitAsync(ct);
  67 + try
  68 + {
  69 + var fresh = await _shopConfigProvider.FetchAllAsync(ct);
  70 + ReplaceAll(fresh);
  71 + return _services.Count;
  72 + }
  73 + finally
  74 + {
  75 + _reloadLock.Release();
  76 + }
23 77 }
24 78  
25 79 /// <summary>按 ShopId 获取对应的 DouyinService;找不到则返回 null。</summary>
... ... @@ -32,6 +86,8 @@ public class DouyinServiceFactory
32 86 /// <summary>获取第一个(默认)DouyinService(兼容单店调用场景)。</summary>
33 87 public DouyinService GetDefaultService()
34 88 {
  89 + if (_services.IsEmpty)
  90 + throw new InvalidOperationException("尚未加载任何抖音店铺配置,请到 ERP 抖音店铺设置中配置后,POST /api/shop-configs/reload 重新加载。");
35 91 return _services.Values.First();
36 92 }
37 93  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs
1 1 using DouyinLogistics.API.Models;
  2 +using Microsoft.Extensions.Caching.Memory;
2 3 using Microsoft.Extensions.DependencyInjection;
3 4 using SqlSugar;
4 5 using System.Text;
... ... @@ -33,10 +34,17 @@ public class OrderService
33 34 private readonly HttpClient _httpClient;
34 35 private readonly ErpApiConfig _erpApiConfig;
35 36 private readonly IServiceScopeFactory _scopeFactory;
  37 + private readonly IMemoryCache _cache;
36 38 private static string? _erpToken = null;
37 39 private static DateTime _tokenExpireTime = DateTime.MinValue;
38 40 private static readonly object _tokenLock = new object();
39 41  
  42 + // 订单列表缓存 TTL:短 TTL 让翻页秒开,又保证订单同步/状态变更后很快可见
  43 + private static readonly TimeSpan OrderListCacheTtl = TimeSpan.FromSeconds(15);
  44 + // 订单列表缓存版本,用于在订单/发货单写入后强制失效(修改时 Interlocked.Increment)
  45 + private static long _orderListCacheVersion = 0;
  46 + private const string OrderListCachePrefix = "orders:list:";
  47 +
40 48 public OrderService(
41 49 ISqlSugarClient db,
42 50 DouyinServiceFactory douyinFactory,
... ... @@ -45,7 +53,8 @@ public class OrderService
45 53 ILogger<OrderService> logger,
46 54 IHttpClientFactory httpClientFactory,
47 55 ErpApiConfig erpApiConfig,
48   - IServiceScopeFactory scopeFactory)
  56 + IServiceScopeFactory scopeFactory,
  57 + IMemoryCache cache)
49 58 {
50 59 _db = db;
51 60 _douyinFactory = douyinFactory;
... ... @@ -55,6 +64,15 @@ public class OrderService
55 64 _httpClient = httpClientFactory.CreateClient();
56 65 _erpApiConfig = erpApiConfig;
57 66 _scopeFactory = scopeFactory;
  67 + _cache = cache;
  68 + }
  69 +
  70 + /// <summary>
  71 + /// 失效订单列表缓存;订单同步、状态变更、发货单创建/更新后调用,避免用户看到过期数据
  72 + /// </summary>
  73 + public static void InvalidateOrderListCache()
  74 + {
  75 + System.Threading.Interlocked.Increment(ref _orderListCacheVersion);
58 76 }
59 77  
60 78 /// <summary>
... ... @@ -465,6 +483,8 @@ public class OrderService
465 483 {
466 484 _logger.LogInformation("订单同步完成 - 新增: {NewCount}, 更新: {UpdateCount}, 总计: {TotalCount}, 总耗时: {TotalElapsed} 秒",
467 485 newOrderCount, updateOrderCount, douyinOrders.Count, totalElapsed.ToString("F2"));
  486 + // 有新增/更新:让下一次列表请求立刻感知变化
  487 + InvalidateOrderListCache();
468 488 }
469 489 else
470 490 {
... ... @@ -660,23 +680,47 @@ public class OrderService
660 680 query = query.Where(o => o.DouyinOrderTime != null && o.DouyinOrderTime <= douyinOrderTimeEnd.Value.AddDays(1).AddSeconds(-1));
661 681 }
662 682  
663   - // 所有状态都参与合并(按买家+地址分组),已退款/退款中/已取消从合并组中剔除
664   - var allOrders = await query
665   - .OrderBy("COALESCE(PayTime, DouyinOrderTime, CreateTime) ASC")
666   - .ToListAsync();
667   - var merged = MergeOrdersByBuyerAndAddress(allOrders);
668   - if (pendingShipmentForm || hasWaybill.HasValue)
669   - merged = await FilterMergedOrdersPostProcessAsync(merged, hasWaybill, pendingShipmentForm);
670   - merged = await EnrichMergedShipmentFormStateAsync(merged);
671   - if (shipmentFormSubmitted == true)
672   - {
673   - merged = merged
674   - .Where(m => m.Status == 1 && m.HasSubmittedShipmentForm)
675   - .ToList();
  683 + // 过滤条件完全一致时,10~15 秒内的重复翻页请求命中缓存,避免重复拉全表 + 重复合并
  684 + var cacheKey = BuildOrderListCacheKey(
  685 + shopId, status, orderId, receiverName, receiverPhone, trackingNumber, productName,
  686 + hasWaybill, pendingShipmentForm, createTimeStart, createTimeEnd,
  687 + payTimeStart, payTimeEnd, douyinOrderTimeStart, douyinOrderTimeEnd,
  688 + shipmentFormSubmitted);
  689 +
  690 + List<Order> merged;
  691 + if (_cache.TryGetValue<List<Order>>(cacheKey, out var cached) && cached != null)
  692 + {
  693 + merged = cached;
676 694 }
677   - merged = ApplyMergedOrderSort(merged, sortBy, sortOrder);
678   - var total = merged.Count;
679   - var paged = merged
  695 + else
  696 + {
  697 + // 所有状态都参与合并(按买家+地址分组),已退款/退款中/已取消从合并组中剔除
  698 + var allOrders = await query
  699 + .OrderBy("COALESCE(PayTime, DouyinOrderTime, CreateTime) ASC")
  700 + .ToListAsync();
  701 + merged = MergeOrdersByBuyerAndAddress(allOrders);
  702 +
  703 + // 合并 Waybill 查询:以前会分别在 FilterMergedOrdersPostProcessAsync、EnrichMergedShipmentFormStateAsync 里各查一次
  704 + // 这里一次性查好,两个逻辑共用,减少一次 WHERE IN (几百~几千 ID) 的重 SQL
  705 + merged = await EnrichAndFilterMergedAsync(merged, hasWaybill, pendingShipmentForm);
  706 + if (shipmentFormSubmitted == true)
  707 + {
  708 + merged = merged
  709 + .Where(m => m.Status == 1 && m.HasSubmittedShipmentForm)
  710 + .ToList();
  711 + }
  712 +
  713 + _cache.Set(cacheKey, merged, new MemoryCacheEntryOptions
  714 + {
  715 + AbsoluteExpirationRelativeToNow = OrderListCacheTtl,
  716 + Size = merged.Count
  717 + });
  718 + }
  719 +
  720 + // 排序和分页保持在内存里做,命中缓存时也能按不同的 sortBy/pageIndex 快速响应
  721 + var sorted = ApplyMergedOrderSort(merged, sortBy, sortOrder);
  722 + var total = sorted.Count;
  723 + var paged = sorted
680 724 .Skip((pageIndex - 1) * pageSize)
681 725 .Take(pageSize)
682 726 .ToList();
... ... @@ -684,6 +728,93 @@ public class OrderService
684 728 }
685 729  
686 730 /// <summary>
  731 + /// 构造订单列表缓存 Key:包含所有影响"候选结果集"的过滤条件和版本号。
  732 + /// 不包含 sortBy/sortOrder/pageIndex/pageSize,这些在内存里处理。
  733 + /// </summary>
  734 + private static string BuildOrderListCacheKey(
  735 + long? shopId, int? status, string? orderId, string? receiverName, string? receiverPhone,
  736 + string? trackingNumber, string? productName, bool? hasWaybill, bool pendingShipmentForm,
  737 + DateTime? createTimeStart, DateTime? createTimeEnd,
  738 + DateTime? payTimeStart, DateTime? payTimeEnd,
  739 + DateTime? douyinOrderTimeStart, DateTime? douyinOrderTimeEnd,
  740 + bool? shipmentFormSubmitted)
  741 + {
  742 + string F(DateTime? d) => d?.ToString("yyyyMMddHHmmss") ?? "";
  743 + return string.Concat(
  744 + OrderListCachePrefix,
  745 + System.Threading.Interlocked.Read(ref _orderListCacheVersion), "|",
  746 + shopId?.ToString() ?? "", "|",
  747 + status?.ToString() ?? "", "|",
  748 + orderId?.Trim() ?? "", "|",
  749 + receiverName?.Trim() ?? "", "|",
  750 + receiverPhone?.Trim() ?? "", "|",
  751 + trackingNumber?.Trim() ?? "", "|",
  752 + productName?.Trim() ?? "", "|",
  753 + hasWaybill?.ToString() ?? "", "|",
  754 + pendingShipmentForm, "|",
  755 + F(createTimeStart), "|", F(createTimeEnd), "|",
  756 + F(payTimeStart), "|", F(payTimeEnd), "|",
  757 + F(douyinOrderTimeStart), "|", F(douyinOrderTimeEnd), "|",
  758 + shipmentFormSubmitted?.ToString() ?? "");
  759 + }
  760 +
  761 + /// <summary>
  762 + /// 合并 FilterMergedOrdersPostProcessAsync + EnrichMergedShipmentFormStateAsync 的逻辑:
  763 + /// 只查一次 Waybill 表,同时完成过滤(hasWaybill / pendingShipmentForm)和状态补充(HasSubmittedShipmentForm / IsPendingShipmentForm)。
  764 + /// </summary>
  765 + private async Task<List<Order>> EnrichAndFilterMergedAsync(
  766 + List<Order> merged, bool? hasWaybill, bool pendingShipmentForm)
  767 + {
  768 + if (merged == null || merged.Count == 0)
  769 + return merged ?? new List<Order>();
  770 +
  771 + var allIds = merged
  772 + .SelectMany(m => m.MergedOrderIds != null && m.MergedOrderIds.Count > 0
  773 + ? m.MergedOrderIds
  774 + : new List<int> { m.Id })
  775 + .Distinct()
  776 + .ToList();
  777 +
  778 + var wbRows = await _db.Queryable<Waybill>()
  779 + .Where(w => allIds.Contains(w.OrderId))
  780 + .Select(w => new { w.OrderId, w.SalesOrderId })
  781 + .ToListAsync();
  782 +
  783 + var hasWaybillSet = new HashSet<int>(wbRows.Select(w => w.OrderId));
  784 + var submittedSet = new HashSet<int>(wbRows
  785 + .Where(w => !string.IsNullOrWhiteSpace(w.SalesOrderId))
  786 + .Select(w => w.OrderId));
  787 +
  788 + var result = new List<Order>(merged.Count);
  789 + foreach (var m in merged)
  790 + {
  791 + var ids = m.MergedOrderIds != null && m.MergedOrderIds.Count > 0
  792 + ? m.MergedOrderIds
  793 + : new List<int> { m.Id };
  794 +
  795 + var hasWb = ids.Any(id => hasWaybillSet.Contains(id)) || !string.IsNullOrWhiteSpace(m.TrackingNumber);
  796 + var hasSubmitted = ids.Any(id => submittedSet.Contains(id));
  797 +
  798 + // 过滤
  799 + if (pendingShipmentForm)
  800 + {
  801 + if (!hasWb || hasSubmitted) continue;
  802 + }
  803 + else if (hasWaybill.HasValue)
  804 + {
  805 + if (hasWaybill.Value != hasWb) continue;
  806 + }
  807 +
  808 + // 补充状态字段
  809 + m.HasSubmittedShipmentForm = hasSubmitted;
  810 + m.IsPendingShipmentForm = m.Status == 1 && hasWb && !hasSubmitted;
  811 + result.Add(m);
  812 + }
  813 +
  814 + return result;
  815 + }
  816 +
  817 + /// <summary>
687 818 /// 合并订单规则:
688 819 /// 1. 待发货(0):同买家同地址 → 合并(用于一起发货)
689 820 /// 2. 已发货(1):同运单号 → 合并(之前合并发货的继续合并展示,分开发货的分开展示)
... ... @@ -711,7 +842,25 @@ public class OrderService
711 842 result.AddRange(refundingOrders);
712 843  
713 844 // 规则1:待发货按同买家同地址合并
714   - var pendingGroups = pendingOrders
  845 + // 用户手动标记 NoMerge=true 的订单不参与合并,单独显示(走 "拆分" 按钮把订单从合并组里拆出来)
  846 + var pendingNoMerge = pendingOrders.Where(o => o.NoMerge).ToList();
  847 + var pendingMergeable = pendingOrders.Where(o => !o.NoMerge).ToList();
  848 +
  849 + // 对每个 NoMerge 订单判断是否还"能恢复合并":
  850 + // - 同买家+同地址下,待发货订单总数(含自己、含其他 NoMerge)>=2 才有合并价值
  851 + // - 只剩它一个待发货的(伙伴都发货/取消了)就不显示"恢复合并"按钮
  852 + var pendingKeyCount = pendingOrders
  853 + .GroupBy(o => GetBuyerAddressKey(o))
  854 + .ToDictionary(g => g.Key, g => g.Count());
  855 + foreach (var o in pendingNoMerge)
  856 + {
  857 + var cnt = pendingKeyCount.TryGetValue(GetBuyerAddressKey(o), out var n) ? n : 1;
  858 + o.CanUnsplit = cnt >= 2;
  859 + }
  860 +
  861 + result.AddRange(pendingNoMerge);
  862 +
  863 + var pendingGroups = pendingMergeable
715 864 .GroupBy(o => GetBuyerAddressKey(o))
716 865 .ToList();
717 866 foreach (var g in pendingGroups)
... ... @@ -726,10 +875,11 @@ public class OrderService
726 875 }
727 876  
728 877 // 规则2:已发货按同运单号合并(只有共享运单号的才是一起发货的)
729   - var shippedWithTracking = shippedOrders.Where(o => !string.IsNullOrWhiteSpace(o.TrackingNumber)).ToList();
730   - var shippedNoTracking = shippedOrders.Where(o => string.IsNullOrWhiteSpace(o.TrackingNumber)).ToList();
  878 + // 已拆分的订单(NoMerge=true)也走单独显示,避免历史标记影响已发货展示
  879 + var shippedWithTracking = shippedOrders.Where(o => !string.IsNullOrWhiteSpace(o.TrackingNumber) && !o.NoMerge).ToList();
  880 + var shippedNoTracking = shippedOrders.Where(o => string.IsNullOrWhiteSpace(o.TrackingNumber) || o.NoMerge).ToList();
731 881  
732   - // 无运单号的已发货订单单独显示
  882 + // 无运单号 / 已被用户拆分的已发货订单单独显示
733 883 result.AddRange(shippedNoTracking);
734 884  
735 885 // 有运单号的按运单号分组
... ... @@ -753,6 +903,67 @@ public class OrderService
753 903 .ToList();
754 904 }
755 905  
  906 + /// <summary>
  907 + /// 拆分合并单:把指定订单 id 集合上的 NoMerge 置 true,后续列表不再把它们合并到同买家+同地址的组里。
  908 + /// 已发货订单(status=1)不应拆分:合并是按运单号识别,拆了也没实际意义,这里直接忽略。
  909 + /// </summary>
  910 + /// <returns>实际被更新的订单数量</returns>
  911 + public async Task<int> SplitMergedOrdersAsync(IEnumerable<int> orderIds)
  912 + {
  913 + var ids = orderIds?.Distinct().ToList() ?? new List<int>();
  914 + if (ids.Count == 0) return 0;
  915 +
  916 + var rows = await _db.Queryable<Order>()
  917 + .Where(o => ids.Contains(o.Id))
  918 + .ToListAsync();
  919 +
  920 + // 只对「待发货/未拆分」订单做拆分;已拆分/已发货/已取消/已退款一律不动
  921 + var toUpdate = rows
  922 + .Where(o => !o.NoMerge && o.Status == 0)
  923 + .ToList();
  924 + if (toUpdate.Count == 0) return 0;
  925 +
  926 + foreach (var o in toUpdate)
  927 + {
  928 + o.NoMerge = true;
  929 + o.UpdateTime = DateTime.Now;
  930 + }
  931 +
  932 + await _db.Updateable(toUpdate)
  933 + .UpdateColumns(o => new { o.NoMerge, o.UpdateTime })
  934 + .ExecuteCommandAsync();
  935 +
  936 + InvalidateOrderListCache();
  937 + return toUpdate.Count;
  938 + }
  939 +
  940 + /// <summary>
  941 + /// 恢复合并:把指定订单的 NoMerge 置 false,下次列表按同买家+同地址重新合并。
  942 + /// </summary>
  943 + public async Task<int> UnsplitOrdersAsync(IEnumerable<int> orderIds)
  944 + {
  945 + var ids = orderIds?.Distinct().ToList() ?? new List<int>();
  946 + if (ids.Count == 0) return 0;
  947 +
  948 + var rows = await _db.Queryable<Order>()
  949 + .Where(o => ids.Contains(o.Id) && o.NoMerge)
  950 + .ToListAsync();
  951 + if (rows.Count == 0) return 0;
  952 +
  953 + foreach (var o in rows)
  954 + {
  955 + o.NoMerge = false;
  956 + o.UpdateTime = DateTime.Now;
  957 + }
  958 +
  959 + await _db.Updateable(rows)
  960 + .UpdateColumns(o => new { o.NoMerge, o.UpdateTime })
  961 + .ExecuteCommandAsync();
  962 +
  963 + InvalidateOrderListCache();
  964 + return rows.Count;
  965 + }
  966 +
756 967 private string GetBuyerAddressKey(Order o)
757 968 {
758 969 var shopPart = (o.ShopId ?? 0L).ToString();
... ... @@ -792,6 +1003,34 @@ public class OrderService
792 1003 first.MergedOrderStatuses = list.Select(o => o.Status).ToList();
793 1004 first.PayAmount = list.Sum(o => o.PayAmount ?? 0);
794 1005 first.OrderAmount = list.Sum(o => o.OrderAmount ?? 0);
  1006 +
  1007 + // 合并所有订单的买家留言和卖家备注(每个订单一行,带订单号前缀)
  1008 + if (list.Count > 1)
  1009 + {
  1010 + var buyerLines = list
  1011 + .Where(o => !string.IsNullOrWhiteSpace(o.BuyerWords))
  1012 + .Select(o =>
  1013 + {
  1014 + var tail = !string.IsNullOrEmpty(o.OrderId) && o.OrderId.Length >= 6
  1015 + ? o.OrderId.Substring(o.OrderId.Length - 6)
  1016 + : (o.OrderId ?? "");
  1017 + return $"[{tail}] {o.BuyerWords}";
  1018 + })
  1019 + .ToList();
  1020 + first.BuyerWords = buyerLines.Count > 0 ? string.Join("\n", buyerLines) : "";
  1021 +
  1022 + var sellerLines = list
  1023 + .Where(o => !string.IsNullOrWhiteSpace(o.SellerWords))
  1024 + .Select(o =>
  1025 + {
  1026 + var tail = !string.IsNullOrEmpty(o.OrderId) && o.OrderId.Length >= 6
  1027 + ? o.OrderId.Substring(o.OrderId.Length - 6)
  1028 + : (o.OrderId ?? "");
  1029 + return $"[{tail}] {o.SellerWords}";
  1030 + })
  1031 + .ToList();
  1032 + first.SellerWords = sellerLines.Count > 0 ? string.Join("\n", sellerLines) : "";
  1033 + }
795 1034 var douyinTimes = list.Where(x => x.DouyinOrderTime.HasValue).Select(x => x.DouyinOrderTime!.Value).ToList();
796 1035 if (douyinTimes.Count > 0)
797 1036 first.DouyinOrderTime = douyinTimes.Min();
... ... @@ -1408,7 +1647,9 @@ public class OrderService
1408 1647 string? overrideSkzh = null,
1409 1648 string? djlx = null,
1410 1649 string? douyinOrderIds = null,
1411   - List<int>? mergedOrderInternalIds = null)
  1650 + List<int>? mergedOrderInternalIds = null,
  1651 + decimal? merchantIncome = null,
  1652 + string? fhr = null)
1412 1653 {
1413 1654 var result = new CreateSalesOrderResult();
1414 1655 var finalDjlx = string.IsNullOrEmpty(djlx) ? "销售出库单" : djlx;
... ... @@ -1488,30 +1729,30 @@ public class OrderService
1488 1729 return result;
1489 1730 }
1490 1731  
1491   - // 4. 获取出库仓库
  1732 + // 4. 获取出库仓库(优先级:发货单 cjck > 抖音店铺设置 ck;不再兜底全局默认仓库,避免误发)
1492 1733 if (waybill != null && !string.IsNullOrEmpty(waybill.Cjck))
1493 1734 {
1494 1735 warehouseId = waybill.Cjck;
1495 1736 _logger.LogInformation("从发货单获取出库仓库: OrderId={OrderId}, WarehouseId={WarehouseId}", orderId, warehouseId);
1496 1737 }
1497   - else
  1738 + else if (order.ShopId.HasValue)
1498 1739 {
1499 1740 try
1500 1741 {
1501   - var warehouseApiUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/WtMd?currentPage=1&pageSize=1";
1502   - var warehouseRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Get, warehouseApiUrl);
1503   - var warehouseResponse = await _httpClient.SendAsync(warehouseRequest);
1504   - if (warehouseResponse.IsSuccessStatusCode)
  1742 + var shopCkUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/WtDyDpsz/by-shop/{order.ShopId.Value}";
  1743 + var shopCkReq = await CreateAuthenticatedRequestAsync(HttpMethod.Get, shopCkUrl);
  1744 + var shopCkRes = await _httpClient.SendAsync(shopCkReq);
  1745 + if (shopCkRes.IsSuccessStatusCode)
1505 1746 {
1506   - var warehouseContent = await warehouseResponse.Content.ReadAsStringAsync();
1507   - var warehouseResult = JsonConvert.DeserializeObject<JObject>(warehouseContent);
1508   - if (warehouseResult != null && warehouseResult["code"]?.ToString() == "200")
  1747 + var shopCkContent = await shopCkRes.Content.ReadAsStringAsync();
  1748 + var shopCkRoot = JsonConvert.DeserializeObject<JObject>(shopCkContent);
  1749 + if (shopCkRoot != null && shopCkRoot["code"]?.ToString() == "200")
1509 1750 {
1510   - var warehouseList = warehouseResult["data"]?["list"] as JArray;
1511   - if (warehouseList != null && warehouseList.Count > 0)
  1751 + var shopCk = shopCkRoot["data"]?["ck"]?.ToString();
  1752 + if (!string.IsNullOrEmpty(shopCk))
1512 1753 {
1513   - warehouseId = warehouseList[0]?["id"]?.ToString() ?? "";
1514   - _logger.LogInformation("获取默认门店ID: OrderId={OrderId}, WarehouseId={WarehouseId}", orderId, warehouseId);
  1754 + warehouseId = shopCk;
  1755 + _logger.LogInformation("从抖音店铺设置获取出库仓库: ShopId={ShopId}, WarehouseId={WarehouseId}", order.ShopId.Value, warehouseId);
1515 1756 }
1516 1757 }
1517 1758 }
... ... @@ -1603,68 +1844,113 @@ public class OrderService
1603 1844 return result;
1604 1845 }
1605 1846  
1606   - // 5.5 自动库存校验:解析完商品后调用 BatchCheckStock,库存不足自动切换为预售出库单
  1847 + // 5.5 库存校验:抖音发货只支持销售出库单(不再自动转预售)
  1848 + // 只要有商品库存不足或缺少商品编码,直接报错,让用户去 ERP 补货或在抖音侧取消发货
1607 1849 if (finalDjlx == "销售出库单" && !string.IsNullOrEmpty(warehouseId))
1608 1850 {
1609   - // 没有 spbm(商品编码)的商品无法通过 API 校验库存,直接视为库存不可用
1610 1851 var itemsWithoutSpbm = resolvedItems.Where(r => string.IsNullOrEmpty(r.sptm)).ToList();
1611 1852 if (itemsWithoutSpbm.Count > 0)
1612 1853 {
1613   - finalDjlx = "预售出库单";
1614 1854 var names = string.Join("、", itemsWithoutSpbm.Select(r => r.spmc));
1615   - _logger.LogInformation("商品无编码(spbm)无法校验库存,自动切换为预售出库单: OrderId={OrderId}, 商品={Names}", orderId, names);
  1855 + _logger.LogWarning("商品无编码(spbm)无法校验库存,拒绝发货: OrderId={OrderId}, 商品={Names}", orderId, names);
  1856 + result.ErrorMessage = $"以下商品未关联 ERP 商品编码,无法校验库存,请先在 ERP 商品档案补齐:{names}";
  1857 + return result;
1616 1858 }
1617   - else
  1859 +
  1860 + try
1618 1861 {
1619   - try
  1862 + var stockItems = resolvedItems
  1863 + .Select(r => new { spbm = r.sptm, quantity = r.sl })
  1864 + .ToList();
  1865 +
  1866 + var stockPayload = new { storeId = warehouseId, items = stockItems };
  1867 + var stockJson = JsonConvert.SerializeObject(stockPayload);
  1868 + var stockContent = new StringContent(stockJson, Encoding.UTF8, "application/json");
  1869 + var stockUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/wtsp/BatchCheckStock";
  1870 + var stockRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Post, stockUrl, stockContent);
  1871 + var stockResponse = await _httpClient.SendAsync(stockRequest);
  1872 +
  1873 + if (!stockResponse.IsSuccessStatusCode)
1620 1874 {
1621   - var stockItems = resolvedItems
1622   - .Select(r => new { spbm = r.sptm, quantity = r.sl })
1623   - .ToList();
1624   -
1625   - var stockPayload = new { storeId = warehouseId, items = stockItems };
1626   - var stockJson = JsonConvert.SerializeObject(stockPayload);
1627   - var stockContent = new StringContent(stockJson, Encoding.UTF8, "application/json");
1628   - var stockUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/wtsp/BatchCheckStock";
1629   - var stockRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Post, stockUrl, stockContent);
1630   - var stockResponse = await _httpClient.SendAsync(stockRequest);
1631   -
1632   - if (stockResponse.IsSuccessStatusCode)
1633   - {
1634   - var stockResponseContent = await stockResponse.Content.ReadAsStringAsync();
1635   - var stockResult = JsonConvert.DeserializeObject<JObject>(stockResponseContent);
1636   - var stockApiOk = stockResult?["success"]?.ToObject<bool>() ?? false;
1637   - if (!stockApiOk)
1638   - {
1639   - var errMsg = stockResult?["msg"]?.ToString() ?? "";
1640   - _logger.LogWarning("库存校验返回失败: OrderId={OrderId}, Msg={Msg}", orderId, errMsg);
1641   - finalDjlx = "预售出库单";
1642   - }
1643   - else
1644   - {
1645   - // 缺省按「不充足」处理,避免缺字段时误判为销售出库单
1646   - var allSufficient = stockResult?["allSufficient"]?.ToObject<bool>() ?? false;
1647   - if (!allSufficient)
1648   - {
1649   - finalDjlx = "预售出库单";
1650   - _logger.LogInformation("库存不足,自动切换为预售出库单: OrderId={OrderId}", orderId);
1651   - }
1652   - else
1653   - {
1654   - _logger.LogInformation("库存充足,使用销售出库单: OrderId={OrderId}", orderId);
1655   - }
1656   - }
1657   - }
1658   - else
  1875 + _logger.LogWarning("库存校验API返回非成功状态: StatusCode={StatusCode}, OrderId={OrderId}",
  1876 + stockResponse.StatusCode, orderId);
  1877 + result.ErrorMessage = $"调用 ERP 库存校验接口失败(HTTP {(int)stockResponse.StatusCode}),请稍后重试";
  1878 + return result;
  1879 + }
  1880 +
  1881 + var stockResponseContent = await stockResponse.Content.ReadAsStringAsync();
  1882 + _logger.LogDebug("BatchCheckStock 原始返回: OrderId={OrderId}, Body={Body}", orderId, stockResponseContent);
  1883 + var stockResult = JsonConvert.DeserializeObject<JObject>(stockResponseContent);
  1884 +
  1885 + // NCC 动态 API 会把 Controller 返回值再包一层 { code, msg, data }:
  1886 + // - 外层:{ code: 200, msg: "操作成功", data: { success: true, data: [...], allSufficient: true } }
  1887 + // - 这里只关心真正 BatchCheckStock 返回的那层(payload),并同时兼容没有包装时的裸返回
  1888 + JObject? payload = stockResult;
  1889 + var ncCode = stockResult?["code"]?.ToObject<int?>();
  1890 + if (ncCode.HasValue)
  1891 + {
  1892 + if (ncCode.Value != 200)
1659 1893 {
1660   - _logger.LogWarning("库存校验API返回非成功状态: StatusCode={StatusCode}, OrderId={OrderId}",
1661   - stockResponse.StatusCode, orderId);
  1894 + var ncMsg = stockResult?["msg"]?.ToString() ?? "调用 ERP 库存校验接口失败";
  1895 + _logger.LogWarning("库存校验API返回异常: OrderId={OrderId}, Code={Code}, Msg={Msg}", orderId, ncCode.Value, ncMsg);
  1896 + result.ErrorMessage = $"调用 ERP 库存校验接口失败:{ncMsg}";
  1897 + return result;
1662 1898 }
  1899 + payload = stockResult?["data"] as JObject;
  1900 + }
  1901 +
  1902 + var stockApiOk = payload?["success"]?.ToObject<bool>() ?? false;
  1903 + if (!stockApiOk)
  1904 + {
  1905 + var errMsg = payload?["msg"]?.ToString() ?? "库存校验失败";
  1906 + _logger.LogWarning("库存校验返回失败: OrderId={OrderId}, Msg={Msg}", orderId, errMsg);
  1907 + result.ErrorMessage = $"库存校验失败:{errMsg}";
  1908 + return result;
1663 1909 }
1664   - catch (Exception ex)
  1910 +
  1911 + var allSufficient = payload?["allSufficient"]?.ToObject<bool>() ?? false;
  1912 + if (!allSufficient)
1665 1913 {
1666   - _logger.LogWarning(ex, "库存校验失败,继续使用{Djlx}: OrderId={OrderId}", finalDjlx, orderId);
  1914 + // 把具体缺货的商品名和库存信息拼出来返回给前端
  1915 + var insufficientList = payload?["data"] as JArray;
  1916 + var insufficientMsgs = new List<string>();
  1917 + if (insufficientList != null)
  1918 + {
  1919 + foreach (var it in insufficientList)
  1920 + {
  1921 + var suf = it?["sufficient"]?.ToObject<bool>() ?? true;
  1922 + if (suf) continue;
  1923 + // 对齐 ERP BatchCheckStock 的字段:productName / available / required
  1924 + var name = it?["productName"]?.ToString()
  1925 + ?? it?["spmc"]?.ToString()
  1926 + ?? it?["spbm"]?.ToString()
  1927 + ?? "";
  1928 + var stock = it?["available"]?.ToString()
  1929 + ?? it?["stock"]?.ToString()
  1930 + ?? "0";
  1931 + var need = it?["required"]?.ToString()
  1932 + ?? it?["quantity"]?.ToString()
  1933 + ?? "";
  1934 + insufficientMsgs.Add(string.IsNullOrEmpty(need)
  1935 + ? $"{name}(库存 {stock})"
  1936 + : $"{name}(库存 {stock},需要 {need})");
  1937 + }
  1938 + }
  1939 + var detail = insufficientMsgs.Count > 0 ? string.Join(";", insufficientMsgs) : "";
  1940 + _logger.LogInformation("库存不足,拒绝发货: OrderId={OrderId}, 缺货明细={Detail}", orderId, detail);
  1941 + result.ErrorMessage = string.IsNullOrEmpty(detail)
  1942 + ? "商品库存不足,无法发货,请先到 ERP 补充库存后再试"
  1943 + : $"商品库存不足,无法发货:{detail}";
  1944 + return result;
1667 1945 }
  1946 +
  1947 + _logger.LogInformation("库存充足,使用销售出库单: OrderId={OrderId}", orderId);
  1948 + }
  1949 + catch (Exception ex)
  1950 + {
  1951 + _logger.LogError(ex, "库存校验异常,拒绝发货: OrderId={OrderId}", orderId);
  1952 + result.ErrorMessage = $"库存校验异常:{ex.Message}";
  1953 + return result;
1668 1954 }
1669 1955 }
1670 1956  
... ... @@ -1727,13 +2013,13 @@ public class OrderService
1727 2013 var defaultsData = defaultsResult?["data"];
1728 2014 if (defaultsData != null)
1729 2015 {
1730   - if (string.IsNullOrEmpty(warehouseId))
1731   - warehouseId = defaultsData["mrck"]?.ToString() ?? "";
  2016 + // 出库仓库:不再使用全局默认仓库(该默认仅供 ERP 常规单据使用)。
  2017 + // 抖音发货单仅使用发货单 cjck 或抖音店铺设置 ck。
1732 2018 if (string.IsNullOrEmpty(defaultKh))
1733 2019 defaultKh = defaultsData["mrwldw"]?.ToString() ?? "";
1734 2020 if (string.IsNullOrEmpty(defaultSkzh))
1735 2021 defaultSkzh = defaultsData["mrskzh"]?.ToString() ?? "";
1736   - _logger.LogInformation("获取默认选项设置: cjck={Cjck}, kh={Kh}, skzh={Skzh}", warehouseId, defaultKh, defaultSkzh);
  2022 + _logger.LogInformation("获取默认往来单位/收款账户: kh={Kh}, skzh={Skzh}; 出库仓库={Cjck}", defaultKh, defaultSkzh, warehouseId);
1737 2023 }
1738 2024 }
1739 2025 }
... ... @@ -1769,11 +2055,46 @@ public class OrderService
1769 2055 payAmtFen = order.PayAmount ?? 0L;
1770 2056 }
1771 2057  
  2058 + // 金额口径(与"商家实际收入"功能对齐):
  2059 + // ydje(应收)= 订单应收金额(抖音 orderAmount,汇总合并单)
  2060 + // 用户支付金额 = 抖音 payAmount(汇总合并单),不直接写入 ERP 某个字段
  2061 + // skje(收款金额)= 发货单上的"商家实际收入"(用户可改,默认=用户支付金额)
  2062 + // ysje(优惠金额)= ydje − 用户支付金额(与原口径保持一致,不随商家实际收入联动)
1772 2063 var discountFen = orderAmtFen - payAmtFen;
1773 2064 if (discountFen < 0) discountFen = 0;
1774 2065 var ydjeYuan = orderAmtFen / 100.0m;
1775   - var skjeYuan = payAmtFen / 100.0m;
1776   - var ysjeYuan = discountFen / 100.0m;
  2066 + var payAmtYuan = payAmtFen / 100.0m; // 用户支付金额
  2067 + var ysjeYuan = discountFen / 100.0m; // 优惠 = ydje − 用户支付金额
  2068 +
  2069 + // 优先级:参数传入 merchantIncome > 发货单已保存的 MerchantIncome > 默认=用户支付金额
  2070 + decimal skjeYuan;
  2071 + if (merchantIncome.HasValue)
  2072 + skjeYuan = merchantIncome.Value;
  2073 + else if (waybill?.MerchantIncome != null)
  2074 + skjeYuan = waybill.MerchantIncome.Value;
  2075 + else
  2076 + skjeYuan = payAmtYuan;
  2077 + if (skjeYuan < 0) skjeYuan = 0;
  2078 + _logger.LogInformation(
  2079 + "销售出库单金额口径: OrderId={OrderId}, ydje(应收)={Ydje}, 用户支付={Pay}, skje(商家实收)={Skje}, ysje(优惠=应收-用户支付)={Ysje}",
  2080 + orderId, ydjeYuan, payAmtYuan, skjeYuan, ysjeYuan);
  2081 +
  2082 + // 构建收款渠道明细 JSON(ERP 的"收款渠道明细"读的就是这个字段,skmx 空时 UI 会显示无数据)
  2083 + // 单账户抖音单:[{"skfs":"入账账户","skje":"49.00","skzh":"<账户id>"}]
  2084 + var skmxJson = "";
  2085 + if (!string.IsNullOrEmpty(defaultSkzh) && skjeYuan > 0)
  2086 + {
  2087 + var skmxArr = new JArray
  2088 + {
  2089 + new JObject
  2090 + {
  2091 + ["skfs"] = "入账账户",
  2092 + ["skje"] = skjeYuan.ToString("0.00"),
  2093 + ["skzh"] = defaultSkzh
  2094 + }
  2095 + };
  2096 + skmxJson = skmxArr.ToString(Newtonsoft.Json.Formatting.None);
  2097 + }
1777 2098  
1778 2099 // 合并订单的抖音订单号(逗号分隔)和运单号
1779 2100 var finalDouyinOrderIds = !string.IsNullOrEmpty(douyinOrderIds) ? douyinOrderIds : (order.OrderId ?? "");
... ... @@ -1791,6 +2112,8 @@ public class OrderService
1791 2112 cjck = warehouseId,
1792 2113 rkck = "",
1793 2114 jsr = "",
  2115 + // 发货人:参数传入优先(当前本次提交值),否则回退 waybill 已保存值
  2116 + fhr = !string.IsNullOrWhiteSpace(fhr) ? fhr.Trim() : (waybill?.Fhr ?? ""),
1794 2117 yddh = finalTrackingNumber,
1795 2118 dyddh = finalDouyinOrderIds,
1796 2119 ydje = ydjeYuan,
... ... @@ -1806,7 +2129,7 @@ public class OrderService
1806 2129 gys = "",
1807 2130 djzt = "已审核",
1808 2131 ly = "抖音订单",
1809   - skmx = "",
  2132 + skmx = skmxJson,
1810 2133 wtXsckdMxList = salesOrderDetails
1811 2134 };
1812 2135  
... ... @@ -2153,6 +2476,38 @@ public class OrderService
2153 2476 }
2154 2477  
2155 2478 /// <summary>
  2479 + /// 删除 ERP 侧的销售/预售出库单(用于抖音端编辑发货单时,先清掉旧单再生成新单)
  2480 + /// </summary>
  2481 + /// <param name="salesOrderId">ERP 销售出库单 ID</param>
  2482 + /// <returns>是否删除成功;已不存在、已审核或关联退货单等情况会返回 false 并写入日志</returns>
  2483 + public async Task<bool> DeleteErpSalesOrderAsync(string salesOrderId)
  2484 + {
  2485 + if (string.IsNullOrWhiteSpace(salesOrderId)) return false;
  2486 + try
  2487 + {
  2488 + var url = $"{_erpApiConfig.BaseUrl}/api/Extend/wtxsckd/{salesOrderId}";
  2489 + var request = await CreateAuthenticatedRequestAsync(HttpMethod.Delete, url);
  2490 + var response = await _httpClient.SendAsync(request);
  2491 + var body = await response.Content.ReadAsStringAsync();
  2492 +
  2493 + if (response.IsSuccessStatusCode)
  2494 + {
  2495 + _logger.LogInformation("已删除 ERP 销售出库单: SalesOrderId={SalesOrderId}, Resp={Body}", salesOrderId, body);
  2496 + return true;
  2497 + }
  2498 +
  2499 + _logger.LogWarning("删除 ERP 销售出库单失败: SalesOrderId={SalesOrderId}, Status={Status}, Body={Body}",
  2500 + salesOrderId, response.StatusCode, body);
  2501 + return false;
  2502 + }
  2503 + catch (Exception ex)
  2504 + {
  2505 + _logger.LogError(ex, "删除 ERP 销售出库单异常: SalesOrderId={SalesOrderId}", salesOrderId);
  2506 + return false;
  2507 + }
  2508 + }
  2509 +
  2510 + /// <summary>
2156 2511 /// 创建带认证的HTTP请求
2157 2512 /// </summary>
2158 2513 public async Task<HttpRequestMessage> CreateAuthenticatedRequestAsync(HttpMethod method, string url, HttpContent? content = null)
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs 0 → 100644
  1 +using DouyinLogistics.API.Models;
  2 +using System.Net.Http.Headers;
  3 +using System.Text.Json;
  4 +
  5 +namespace DouyinLogistics.API.Services;
  6 +
  7 +/// <summary>
  8 +/// 从 ERP(WtDyDpsz/internal/all)拉取抖音店铺配置。
  9 +/// 如果 ERP 不可用或未配置任何店铺,则回退到 appsettings.json 中的 DouyinShops。
  10 +/// </summary>
  11 +public class ShopConfigProvider
  12 +{
  13 + private readonly IHttpClientFactory _httpClientFactory;
  14 + private readonly ILogger<ShopConfigProvider> _logger;
  15 + private readonly ErpApiConfig _erpApiConfig;
  16 + private readonly List<DouyinConfig> _fallbackConfigs;
  17 +
  18 + public ShopConfigProvider(
  19 + IHttpClientFactory httpClientFactory,
  20 + ILogger<ShopConfigProvider> logger,
  21 + ErpApiConfig erpApiConfig,
  22 + IEnumerable<DouyinConfig> fallbackConfigs)
  23 + {
  24 + _httpClientFactory = httpClientFactory;
  25 + _logger = logger;
  26 + _erpApiConfig = erpApiConfig;
  27 + _fallbackConfigs = fallbackConfigs?.ToList() ?? new List<DouyinConfig>();
  28 + }
  29 +
  30 + /// <summary>
  31 + /// 拉取最新店铺配置;ERP 拉不到时自动回退 appsettings。
  32 + /// </summary>
  33 + public async Task<List<DouyinConfig>> FetchAllAsync(CancellationToken ct = default)
  34 + {
  35 + if (string.IsNullOrWhiteSpace(_erpApiConfig?.BaseUrl))
  36 + {
  37 + _logger.LogWarning("未配置 ErpApi.BaseUrl,使用 appsettings.json 中的 DouyinShops 作为兜底");
  38 + return _fallbackConfigs;
  39 + }
  40 +
  41 + try
  42 + {
  43 + var client = _httpClientFactory.CreateClient();
  44 + client.Timeout = TimeSpan.FromSeconds(10);
  45 + if (!client.DefaultRequestHeaders.Contains("User-Agent"))
  46 + client.DefaultRequestHeaders.Add("User-Agent", "DouyinLogistics-API/1.0");
  47 + var url = $"{_erpApiConfig.BaseUrl.TrimEnd('/')}/api/Extend/WtDyDpsz/internal/all";
  48 + using var req = new HttpRequestMessage(HttpMethod.Get, url);
  49 + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
  50 + var resp = await client.SendAsync(req, ct);
  51 + var body = await resp.Content.ReadAsStringAsync(ct);
  52 + if (!resp.IsSuccessStatusCode)
  53 + {
  54 + _logger.LogWarning("从 ERP 拉取店铺配置失败: {Status} {Body}", resp.StatusCode, body);
  55 + return _fallbackConfigs;
  56 + }
  57 +
  58 + var list = ParseShops(body);
  59 + if (list.Count == 0)
  60 + {
  61 + _logger.LogWarning("ERP 返回的店铺列表为空,回退到 appsettings");
  62 + return _fallbackConfigs;
  63 + }
  64 + _logger.LogInformation("✅ 已从 ERP 拉取 {Count} 个抖音店铺配置", list.Count);
  65 + return list;
  66 + }
  67 + catch (Exception ex)
  68 + {
  69 + _logger.LogError(ex, "从 ERP 拉取店铺配置异常,回退到 appsettings");
  70 + return _fallbackConfigs;
  71 + }
  72 + }
  73 +
  74 + private static List<DouyinConfig> ParseShops(string json)
  75 + {
  76 + var result = new List<DouyinConfig>();
  77 + if (string.IsNullOrWhiteSpace(json)) return result;
  78 + using var doc = JsonDocument.Parse(json);
  79 + // NCC 框架响应统一为 { code, data, msg, ... }
  80 + JsonElement array;
  81 + if (doc.RootElement.ValueKind == JsonValueKind.Array)
  82 + {
  83 + array = doc.RootElement;
  84 + }
  85 + else if (doc.RootElement.TryGetProperty("data", out var dataElem))
  86 + {
  87 + if (dataElem.ValueKind == JsonValueKind.Array)
  88 + array = dataElem;
  89 + else
  90 + return result;
  91 + }
  92 + else
  93 + {
  94 + return result;
  95 + }
  96 +
  97 + foreach (var item in array.EnumerateArray())
  98 + {
  99 + var cfg = new DouyinConfig
  100 + {
  101 + ShopId = GetLong(item, "shopId"),
  102 + ShopName = GetString(item, "shopName"),
  103 + AppKey = GetString(item, "appKey"),
  104 + AppSecret = GetString(item, "appSecret"),
  105 + CallbackUrl = GetString(item, "callbackUrl"),
  106 + ApiBaseUrl = EmptyFallback(GetString(item, "apiBaseUrl"), "https://openapi-fxg.jinritemai.com"),
  107 + SyncDays = (int)GetLong(item, "syncDays", 30),
  108 + SenderName = GetString(item, "senderName"),
  109 + SenderPhone = GetString(item, "senderPhone"),
  110 + SenderAddress = GetString(item, "senderAddress"),
  111 + SenderProvince = GetString(item, "senderProvince"),
  112 + SenderCity = GetString(item, "senderCity"),
  113 + SenderDistrict = GetString(item, "senderDistrict"),
  114 + SenderStreet = GetString(item, "senderStreet"),
  115 + };
  116 + if (cfg.ShopId > 0 && !string.IsNullOrEmpty(cfg.AppKey) && !string.IsNullOrEmpty(cfg.AppSecret))
  117 + {
  118 + result.Add(cfg);
  119 + }
  120 + }
  121 + return result;
  122 + }
  123 +
  124 + private static string GetString(JsonElement el, string name)
  125 + {
  126 + if (el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
  127 + return prop.GetString() ?? string.Empty;
  128 + return string.Empty;
  129 + }
  130 +
  131 + private static long GetLong(JsonElement el, string name, long def = 0)
  132 + {
  133 + if (!el.TryGetProperty(name, out var prop)) return def;
  134 + return prop.ValueKind switch
  135 + {
  136 + JsonValueKind.Number => prop.TryGetInt64(out var v) ? v : def,
  137 + JsonValueKind.String => long.TryParse(prop.GetString(), out var v) ? v : def,
  138 + _ => def
  139 + };
  140 + }
  141 +
  142 + private static string EmptyFallback(string s, string def) =>
  143 + string.IsNullOrWhiteSpace(s) ? def : s;
  144 +}
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json
... ... @@ -9,24 +9,8 @@
9 9 "ConnectionStrings": {
10 10 "DefaultConnection": "Server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;Port=3306;Database=ncc_wutong;Uid=netteam;Pwd=netteam;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=true;"
11 11 },
12   - "DouyinShops": [
13   - {
14   - "ShopName": "梧桐官方店",
15   - "SyncDays": 30,
16   - "AppKey": "7463313337959335475",
17   - "AppSecret": "648ddb67-b3ba-48a3-905c-ea761e69a87e",
18   - "CallbackUrl": "http://localhost:5070/api/auth/callback",
19   - "ApiBaseUrl": "https://openapi-fxg.jinritemai.com",
20   - "ShopId": 37714425,
21   - "SenderName": "PongGame",
22   - "SenderPhone": "18014801756",
23   - "SenderAddress": "浦洲路39号沿海创中心A区301室",
24   - "SenderProvince": "江苏省",
25   - "SenderCity": "南京市",
26   - "SenderDistrict": "浦口区",
27   - "SenderStreet": "沿江街道"
28   - }
29   - ],
  12 + "__DouyinShops_Readme": "抖音店铺配置已迁移至 ERP「抖音店铺设置」(wt_dy_dpsz),启动时从 ERP 拉取。下面的 DouyinShops 仅作为 ERP 不可用时的兜底,生产环境可留空。",
  13 + "DouyinShops": [],
30 14 "Sf": {
31 15 "CustomerCode": "your_customer_code",
32 16 "CheckWord": "your_check_word",
... ...
Antis.Erp.Plat/douyin/frontend/src/api/order.ts
... ... @@ -36,6 +36,10 @@ export interface Order {
36 36 hasSubmittedShipmentForm?: boolean
37 37 /** 是否“已发货-未提交发货单” */
38 38 isPendingShipmentForm?: boolean
  39 + /** 是否已被用户手动从合并组拆分出来(true 表示不再参与自动合并) */
  40 + noMerge?: boolean
  41 + /** 是否可以"恢复合并":noMerge=true 且同买家+同地址下仍有其他待发货订单 */
  42 + canUnsplit?: boolean
39 43 receiverName: string
40 44 receiverPhone: string
41 45 receiverAddress: string
... ... @@ -195,6 +199,16 @@ export const getMergedOrderDetail = (ids: number[]) =&gt; {
195 199 return api.get('/orders/detail/merged', { params: { ids: ids.join(',') } })
196 200 }
197 201  
  202 +// 拆分合并单:把这组订单从自动合并组中拆出来
  203 +export const splitMergedOrders = (orderIds: number[]) => {
  204 + return api.post('/orders/split', { orderIds })
  205 +}
  206 +
  207 +// 恢复合并:取消"拆分"标记
  208 +export const unsplitOrders = (orderIds: number[]) => {
  209 + return api.post('/orders/unsplit', { orderIds })
  210 +}
  211 +
198 212 // 查询ERP商品列表(pl:单个品类 F_Id;ERP 商品可挂多品类逗号存储,后端按「包含该 ID」匹配)
199 213 export const searchProducts = (keyword?: string, pageIndex = 1, pageSize = 20, pl?: string) => {
200 214 const params: any = { pageIndex, pageSize }
... ... @@ -251,6 +265,11 @@ export const getPaymentAccounts = () =&gt; {
251 265 return api.get('/orders/payment-accounts')
252 266 }
253 267  
  268 +// 获取 ERP 用户列表(用于"发货人"下拉)
  269 +export const getUsers = () => {
  270 + return api.get('/orders/users')
  271 +}
  272 +
254 273 // 获取抖音店铺设置(往来单位/收款账户)
255 274 export const getShopSettings = (shopId: number) => {
256 275 return api.get(`/orders/shop-settings/${shopId}`)
... ...
Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue
... ... @@ -16,27 +16,22 @@
16 16 <div class="serial-number-select">
17 17 <!-- 搜索条件 -->
18 18 <el-form :inline="true" :model="searchForm" class="search-form">
19   - <el-form-item label="商品编号">
20   - <el-input v-model="searchForm.productCode" placeholder="请输入商品编号" readonly></el-input>
21   - </el-form-item>
22 19 <el-form-item label="仓库">
23   - <el-select
24   - v-model="searchForm.warehouse"
25   - placeholder="请选择仓库"
26   - clearable
27   - @change="(val: any) => console.log('仓库下拉框值变化:', val, '当前searchForm.warehouse:', searchForm.warehouse)"
28   - @visible-change="(visible: boolean) => visible && console.log('下拉框打开,当前值:', searchForm.warehouse)"
29   - >
30   - <el-option
31   - v-for="item in warehouseOptions"
32   - :key="String(item.id || item.F_Id)"
33   - :label="item.mdmc || item.F_mdmc || item.name"
34   - :value="String(item.id || item.F_Id)"
35   - ></el-option>
36   - </el-select>
  20 + <!-- 仓库以发货单选择的出库仓库为准,这里只展示不允许修改 -->
  21 + <el-input
  22 + :model-value="currentWarehouseName"
  23 + placeholder="请在发货单中选择出库仓库"
  24 + readonly
  25 + style="width: 200px;"
  26 + ></el-input>
37 27 </el-form-item>
38 28 <el-form-item label="序列号">
39   - <el-input v-model="searchForm.serialNumber" placeholder="支持模糊查询"></el-input>
  29 + <el-input
  30 + v-model="searchForm.serialNumber"
  31 + placeholder="支持模糊查询"
  32 + clearable
  33 + @keyup.enter="searchSerialNumbers"
  34 + ></el-input>
40 35 </el-form-item>
41 36 <el-form-item>
42 37 <el-button type="primary" @click="searchSerialNumbers">查询</el-button>
... ... @@ -44,18 +39,24 @@
44 39 </el-form-item>
45 40 </el-form>
46 41  
47   - <!-- 序列号列表 -->
48   - <el-table
  42 + <!-- 选择数量提示:需要 N 个、已选 M 个 -->
  43 + <div class="count-tip" v-if="maxCount > 0">
  44 + 需选择 <span class="hl">{{ maxCount }}</span> 个,已选
  45 + <span class="hl" :class="{ 'over': selectedSerialNumbers.length > maxCount }">{{ selectedSerialNumbers.length }}</span> 个
  46 + </div>
  47 +
  48 + <!-- 序列号列表(前端分页,防止 1000+ 一次性渲染) -->
  49 + <el-table
49 50 ref="serialTable"
50   - :data="serialNumberList"
51   - size="small"
  51 + :data="pagedList"
  52 + size="small"
  53 + row-key="serialNumber"
52 54 @selection-change="handleSelectionChange"
53 55 v-loading="loading"
54 56 >
55   - <el-table-column type="selection" width="55"></el-table-column>
56   - <el-table-column prop="serialNumber" label="序列号" width="180"></el-table-column>
57   - <el-table-column prop="productCode" label="商品编号" width="120"></el-table-column>
58   - <el-table-column prop="productName" label="商品名称" width="150"></el-table-column>
  57 + <el-table-column type="selection" width="55" reserve-selection :selectable="isRowSelectable"></el-table-column>
  58 + <el-table-column prop="serialNumber" label="序列号" min-width="220" show-overflow-tooltip></el-table-column>
  59 + <el-table-column prop="productName" label="商品名称" min-width="220" show-overflow-tooltip></el-table-column>
59 60 <el-table-column prop="warehouse" label="仓库" width="120">
60 61 <template #default="{ row }">
61 62 {{ getWarehouseName(row.warehouse) }}
... ... @@ -75,12 +76,25 @@
75 76 </el-table-column>
76 77 </el-table>
77 78  
  79 + <!-- 分页 -->
  80 + <div class="pagination-wrap" v-if="serialNumberList.length > pageSize">
  81 + <el-pagination
  82 + v-model:current-page="currentPage"
  83 + v-model:page-size="pageSize"
  84 + :total="serialNumberList.length"
  85 + :page-sizes="[20, 50, 100, 200]"
  86 + layout="total, sizes, prev, pager, next, jumper"
  87 + background
  88 + small
  89 + />
  90 + </div>
  91 +
78 92 <!-- 已选择的序列号 -->
79 93 <div class="selected-serial-numbers" v-if="selectedSerialNumbers.length > 0">
80 94 <h4>已选择的序列号 ({{ selectedSerialNumbers.length }}个):</h4>
81 95 <div class="selected-list">
82   - <el-tag
83   - v-for="serialNumber in selectedSerialNumbers"
  96 + <el-tag
  97 + v-for="serialNumber in selectedSerialNumbers"
84 98 :key="serialNumber"
85 99 closable
86 100 @close="removeSelected(serialNumber)"
... ... @@ -95,19 +109,19 @@
95 109 <template #footer>
96 110 <el-button @click="handleClose">取 消</el-button>
97 111 <el-button type="primary" @click="confirmSelection">
98   - 确 定 ({{ selectedSerialNumbers.length }})
  112 + 确 定 ({{ selectedSerialNumbers.length }}{{ maxCount > 0 ? ` / ${maxCount}` : '' }})
99 113 </el-button>
100 114 </template>
101 115 </el-dialog>
102 116 </template>
103 117  
104 118 <script setup lang="ts">
105   -import { ref, nextTick, watch } from 'vue'
  119 +import { ref, nextTick, computed } from 'vue'
106 120 import axios from 'axios'
107 121 import { ElMessage } from 'element-plus'
108 122  
109 123 // 使用Douyin API(5070端口),避免CORS问题
110   -const apiBaseURL = import.meta.env.VITE_API_BASE_URL ||
  124 +const apiBaseURL = import.meta.env.VITE_API_BASE_URL ||
111 125 (import.meta.env.DEV ? 'http://localhost:5070/api' : '/api')
112 126  
113 127 const api = axios.create({
... ... @@ -117,6 +131,7 @@ const api = axios.create({
117 131  
118 132 const visible = ref(false)
119 133 const loading = ref(false)
  134 +// 仓库由发货单页面传入,不在弹窗里让用户改;productCode 只用于查询参数,不展示
120 135 const searchForm = ref({
121 136 productCode: '',
122 137 warehouse: '',
... ... @@ -128,216 +143,80 @@ const warehouseOptions = ref&lt;any[]&gt;([])
128 143 const currentProductCode = ref('')
129 144 const currentWarehouse = ref('')
130 145 const currentDocumentType = ref('')
  146 +// 商品清单里的需求数量,0 表示不限制
  147 +const maxCount = ref(0)
131 148  
132   -// 获取仓库选项
  149 +// 分页(纯前端分页,后端一次性返回全量)
  150 +const currentPage = ref(1)
  151 +const pageSize = ref(50)
  152 +const pagedList = computed(() => {
  153 + const start = (currentPage.value - 1) * pageSize.value
  154 + return serialNumberList.value.slice(start, start + pageSize.value)
  155 +})
  156 +
  157 +// 未勾选且已选满时,禁止再勾(已勾选的不受影响,允许取消)
  158 +const isRowSelectable = (row: any) => {
  159 + if (!maxCount.value) return true
  160 + const sn = String(row?.serialNumber || '').trim()
  161 + if (selectedSerialNumbers.value.includes(sn)) return true
  162 + return selectedSerialNumbers.value.length < maxCount.value
  163 +}
  164 +
  165 +const currentWarehouseName = computed(() => {
  166 + const id = String(currentWarehouse.value || '').trim()
  167 + if (!id) return ''
  168 + const match = warehouseOptions.value.find((item: any) =>
  169 + String(item.id || item.F_Id || item.Id || '').trim() === id
  170 + )
  171 + return match ? (match.mdmc || match.F_mdmc || match.name || id) : id
  172 +})
  173 +
  174 +// 获取仓库选项(仅用于把仓库 ID 解析成名称展示)
133 175 const getWarehouseOptions = async () => {
134 176 try {
135   - // 使用Douyin API代理接口,避免CORS问题
136 177 const response = await api.get('/orders/warehouses')
137   - console.log('获取仓库选项 - 响应:', response.data)
138 178 if (response.data && response.data.code === 200) {
139 179 warehouseOptions.value = response.data.data || []
140   - console.log('获取到仓库选项数量:', warehouseOptions.value.length, warehouseOptions.value)
141 180 } else if (response.data && Array.isArray(response.data)) {
142 181 warehouseOptions.value = response.data
143 182 } else {
144   - console.warn('仓库选项格式不正确:', response.data)
145 183 warehouseOptions.value = []
146 184 }
147 185 } catch (error) {
148 186 console.error('获取仓库选项失败:', error)
149   - // 如果API调用失败,尝试使用空数组
150 187 warehouseOptions.value = []
151 188 }
152 189 }
153 190  
154   -// 打开弹窗
155   -const open = async (productCode: string, warehouse: string = '', selectedSerialNumbersList: string[] = [], documentType: string = '发货单') => {
  191 +// 打开弹窗:productCode = 发货行商品(F_Id 或 spbm 或抖音SKU),warehouse = 发货单已选的出库仓库 ID
  192 +// max = 商品清单中的需求数量(≤0 表示不限制)
  193 +const open = async (productCode: string, warehouse: string = '', selectedSerialNumbersList: string[] = [], documentType: string = '发货单', max: number = 0) => {
156 194 visible.value = true
157 195 currentProductCode.value = productCode
158   - currentWarehouse.value = warehouse
  196 + currentWarehouse.value = warehouse || ''
159 197 currentDocumentType.value = documentType
160   - searchForm.value.productCode = productCode // 先设置为原始值
  198 + maxCount.value = Math.max(0, Number(max) || 0)
  199 + searchForm.value.productCode = productCode
  200 + searchForm.value.warehouse = warehouse || ''
161 201 searchForm.value.serialNumber = ''
162 202 selectedSerialNumbers.value = [...selectedSerialNumbersList]
163   -
164   - console.log('打开序列号选择弹窗 - 商品编码:', productCode, '仓库:', warehouse, '仓库选项数量:', warehouseOptions.value.length)
165   -
166   - // 确保仓库选项已加载(每次打开弹窗都重新加载,确保数据最新)
167   - await getWarehouseOptions()
168   -
169   - // 设置仓库(在仓库选项加载后设置,确保能正确匹配)
170   - // 使用nextTick确保仓库选项已完全渲染
171   - await nextTick()
172   -
173   - if (warehouse) {
174   - console.log('开始匹配仓库 - 传入值:', warehouse, '类型:', typeof warehouse)
175   - console.log('仓库选项数量:', warehouseOptions.value.length)
176   -
177   - // 尝试匹配仓库ID(支持多种字段名和类型转换)
178   - const warehouseStr = String(warehouse).trim()
179   - let matchedWarehouse = null
180   - let matchedId = null
181   -
182   - // 遍历所有仓库选项,尝试匹配
183   - for (const item of warehouseOptions.value) {
184   - const itemId = item.id || item.F_Id || item.Id || ''
185   - const itemIdStr = String(itemId).trim()
186   -
187   - // 尝试多种匹配方式
188   - if (itemIdStr === warehouseStr ||
189   - item.id === warehouse ||
190   - item.F_Id === warehouse ||
191   - item.Id === warehouse ||
192   - String(item.id) === String(warehouse) ||
193   - String(item.F_Id) === String(warehouse)) {
194   - matchedWarehouse = item
195   - matchedId = item.id || item.F_Id || warehouse
196   - console.log('✅ 找到匹配的仓库:', {
197   - 匹配项: item,
198   - 使用的ID: matchedId,
199   - 原始传入值: warehouse
200   - })
201   - break
202   - }
203   - }
204   -
205   - if (matchedWarehouse && matchedId) {
206   - // 使用与el-option的value相同的格式:item.id || item.F_Id
207   - // 确保值的类型和格式与el-option的value完全一致
208   - const finalWarehouseId = matchedWarehouse.id || matchedWarehouse.F_Id || matchedId
209   - console.log('✅ 成功匹配仓库,准备设置searchForm.warehouse为:', finalWarehouseId)
210   - console.log('匹配到的仓库对象:', matchedWarehouse)
211   - console.log('el-option的value格式应该是:', matchedWarehouse.id || matchedWarehouse.F_Id)
212   -
213   - // 直接设置仓库值,确保类型与el-option的value一致(字符串)
214   - const warehouseValue = String(finalWarehouseId)
215   -
216   - // 先清空,再设置,确保Vue能检测到变化
217   - searchForm.value.warehouse = ''
218   - await nextTick()
219   -
220   - // 设置新值
221   - searchForm.value.warehouse = warehouseValue
222   - console.log('设置后的searchForm.warehouse值:', searchForm.value.warehouse)
223   - console.log('设置值的类型:', typeof searchForm.value.warehouse)
224   - console.log('仓库选项数量:', warehouseOptions.value.length)
225   - console.log('仓库选项的value列表:', warehouseOptions.value.map((item: any) => String(item.id || item.F_Id)))
226   -
227   - // 使用nextTick确保Vue已更新响应式数据和DOM
228   - await nextTick()
229   - console.log('nextTick后的searchForm.warehouse值:', searchForm.value.warehouse)
230   -
231   - // 再次使用nextTick,确保el-select组件已更新
232   - await nextTick()
233   - console.log('第二次nextTick后的searchForm.warehouse值:', searchForm.value.warehouse)
234   -
235   - // 验证值是否在选项中(使用字符串比较)
236   - const verifyValue = String(finalWarehouseId)
237   - const foundOption = warehouseOptions.value.find((item: any) => {
238   - const optionValue = String(item.id || item.F_Id)
239   - return optionValue === verifyValue
240   - })
241   - if (foundOption) {
242   - console.log('✅ 最终验证:仓库值在选项中,匹配的选项:', foundOption)
243   - } else {
244   - console.error('❌ 最终验证失败:仓库值不在选项中')
245   - }
246   - } else {
247   - // 如果没有匹配到,直接使用传入的仓库值(可能是仓库ID)
248   - // 但需要确保格式与el-option的value一致
249   - searchForm.value.warehouse = warehouse
250   - console.log('⚠️ 未在仓库选项中找到匹配的仓库,直接使用传入值:', warehouse)
251   - console.log('仓库选项列表:', warehouseOptions.value.map((item: any) => ({
252   - id: item.id,
253   - F_Id: item.F_Id,
254   - Id: item.Id,
255   - label: item.mdmc || item.F_mdmc || item.name,
256   - 'id类型': typeof item.id,
257   - 'F_Id类型': typeof item.F_Id
258   - })))
259   - }
260   - } else {
261   - searchForm.value.warehouse = ''
262   - console.log('⚠️ 未传入仓库参数')
263   - }
264   -
265   - // 如果productCode是长数字(可能是抖音SKU编码),先尝试转换为ERP商品ID(F_Id)
266   - // 这样输入框和表格中显示的就是F_Id,而不是原始的SKU编码
267   - if (productCode && productCode.length > 10 && /^\d+$/.test(productCode)) {
268   - try {
269   - console.log('检测到可能是抖音SKU编码,尝试转换为ERP商品ID:', productCode)
270   - // 调用后端API获取商品信息(通过SKU编码)
271   - const response = await api.get('/orders/products/info', {
272   - params: { skuId: productCode }
273   - })
274   -
275   - if (response.data && response.data.code === 200 && response.data.data && response.data.data.id) {
276   - const productId = response.data.data.id
277   - console.log('成功将抖音SKU编码转换为ERP商品ID:', productCode, '->', productId)
278   - // 更新输入框中的商品编号为F_Id
279   - searchForm.value.productCode = productId
280   - currentProductCode.value = productId
281   - } else {
282   - console.warn('未找到抖音SKU编码对应的ERP商品ID,使用原值:', productCode)
283   - }
284   - } catch (error) {
285   - console.warn('转换抖音SKU编码失败,使用原值:', productCode, error)
286   - }
  203 + currentPage.value = 1
  204 +
  205 + console.log('[序列号弹窗] 打开: productCode=', productCode, ', warehouse=', warehouse, ', max=', max, ', preSelected=', selectedSerialNumbersList)
  206 +
  207 + // 仓库名展示需要仓库选项列表
  208 + if (warehouseOptions.value.length === 0) {
  209 + await getWarehouseOptions()
287 210 }
288   -
289   - // 再次检查仓库设置(在商品编码转换后,确保仓库值正确)
290   - // 使用nextTick确保仓库选项已完全加载和DOM已更新
  211 +
  212 + // 清空上次残留的选择状态,避免 reserve-selection 跨次保留
  213 + ;(serialTable.value as any)?.clearSelection?.()
  214 +
291 215 await nextTick()
292   - console.log('最终检查 - 仓库值:', searchForm.value.warehouse, '仓库选项数量:', warehouseOptions.value.length)
293   -
294   - // 如果仓库值已设置,验证它是否在选项中
295   - if (searchForm.value.warehouse && warehouseOptions.value.length > 0) {
296   - const finalWarehouse = String(searchForm.value.warehouse).trim()
297   - const found = warehouseOptions.value.find((item: any) => {
298   - const itemId = String(item.id || item.F_Id || '').trim()
299   - return itemId === finalWarehouse
300   - })
301   -
302   - if (!found) {
303   - console.warn('⚠️ 最终检查:仓库值未在选项中找到,当前值:', finalWarehouse)
304   - console.log('仓库选项列表:', warehouseOptions.value.map((item: any) => ({
305   - id: item.id,
306   - F_Id: item.F_Id,
307   - label: item.mdmc || item.F_mdmc || item.name,
308   - 'value格式': item.id || item.F_Id
309   - })))
310   -
311   - // 尝试重新匹配原始传入的warehouse值
312   - if (warehouse) {
313   - const warehouseStr = String(warehouse).trim()
314   - const retryMatch = warehouseOptions.value.find((item: any) => {
315   - const itemId = String(item.id || item.F_Id || '').trim()
316   - return itemId === warehouseStr
317   - })
318   -
319   - if (retryMatch) {
320   - const retryId = retryMatch.id || retryMatch.F_Id || warehouse
321   - searchForm.value.warehouse = retryId
322   - console.log('✅ 重新匹配成功,设置仓库为:', retryId)
323   - // 再次使用nextTick确保DOM更新
324   - await nextTick()
325   - } else {
326   - console.error('❌ 无法匹配仓库,请检查仓库选项和传入值是否一致')
327   - }
328   - }
329   - } else {
330   - console.log('✅ 最终检查:仓库值已正确设置:', finalWarehouse)
331   - }
332   - } else if (warehouse && warehouseOptions.value.length === 0) {
333   - console.warn('⚠️ 仓库选项为空,无法设置仓库值')
334   - }
335   -
336   - searchSerialNumbers().then(() => {
337   - nextTick(() => {
338   - setSelection()
339   - })
340   - })
  216 +
  217 + await searchSerialNumbers()
  218 + await nextTick()
  219 + setSelection()
341 220 }
342 221  
343 222 // 关闭弹窗
... ... @@ -345,6 +224,9 @@ const handleClose = () =&gt; {
345 224 visible.value = false
346 225 serialNumberList.value = []
347 226 selectedSerialNumbers.value = []
  227 + maxCount.value = 0
  228 + currentPage.value = 1
  229 + ;(serialTable.value as any)?.clearSelection?.()
348 230 }
349 231  
350 232 // 查询序列号
... ... @@ -385,6 +267,8 @@ const searchSerialNumbers = async () =&gt; {
385 267 serialNumberList.value = []
386 268 ElMessage.warning('未查询到可用序列号')
387 269 }
  270 + // 重新查询后回到第 1 页
  271 + currentPage.value = 1
388 272 } catch (error: any) {
389 273 console.error('查询序列号失败:', error)
390 274 if (error && error.message !== '操作成功') {
... ... @@ -406,9 +290,25 @@ const resetSearch = () =&gt; {
406 290 searchSerialNumbers()
407 291 }
408 292  
409   -// 处理选择变化
  293 +// 处理选择变化(配合 reserve-selection,selection 是跨页的全量已选)
410 294 const handleSelectionChange = (selection: any[]) => {
411   - selectedSerialNumbers.value = selection.map(item => item.serialNumber)
  295 + let next = selection.map(item => String(item.serialNumber || '').trim()).filter(Boolean)
  296 + // 双保险:即便因极端时序出现超选,截断到 maxCount 并提示
  297 + if (maxCount.value > 0 && next.length > maxCount.value) {
  298 + ElMessage.warning(`最多只能选择 ${maxCount.value} 个序列号`)
  299 + const keep = new Set(next.slice(0, maxCount.value))
  300 + next = next.slice(0, maxCount.value)
  301 + // 取消多余勾选
  302 + nextTick(() => {
  303 + serialNumberList.value.forEach((row: any) => {
  304 + const sn = String(row.serialNumber || '').trim()
  305 + if (!keep.has(sn)) {
  306 + (serialTable.value as any)?.toggleRowSelection?.(row, false)
  307 + }
  308 + })
  309 + })
  310 + }
  311 + selectedSerialNumbers.value = next
412 312 }
413 313  
414 314 // 移除已选择的序列号
... ... @@ -429,6 +329,10 @@ const serialTable = ref()
429 329 // 确认选择
430 330 const emit = defineEmits(['confirm'])
431 331 const confirmSelection = () => {
  332 + if (maxCount.value > 0 && selectedSerialNumbers.value.length !== maxCount.value) {
  333 + ElMessage.warning(`商品清单数量为 ${maxCount.value},当前已选 ${selectedSerialNumbers.value.length} 个,数量不一致`)
  334 + return
  335 + }
432 336 emit('confirm', selectedSerialNumbers.value)
433 337 handleClose()
434 338 }
... ... @@ -528,6 +432,28 @@ defineExpose({
528 432 flex-wrap: wrap;
529 433 gap: 5px;
530 434 }
  435 +
  436 +.count-tip {
  437 + margin-bottom: 10px;
  438 + font-size: 13px;
  439 + color: #606266;
  440 +}
  441 +
  442 +.count-tip .hl {
  443 + font-weight: 600;
  444 + color: #409eff;
  445 + margin: 0 3px;
  446 +}
  447 +
  448 +.count-tip .hl.over {
  449 + color: #f56c6c;
  450 +}
  451 +
  452 +.pagination-wrap {
  453 + display: flex;
  454 + justify-content: flex-end;
  455 + margin-top: 12px;
  456 +}
531 457 </style>
532 458  
533 459 <style>
... ...
Antis.Erp.Plat/douyin/frontend/src/views/CreateWaybillView.vue
... ... @@ -31,10 +31,40 @@
31 31 <el-row :gutter="16">
32 32 <!-- 左列:基本信息 -->
33 33 <el-col :span="10">
  34 + <!-- 已发货/已打印:回显物流公司 + 物流单号,支持复制 -->
  35 + <el-form-item v-if="waybillTrackingNumber" label="物流单号">
  36 + <div class="tracking-wrap">
  37 + <el-tag v-if="waybillLogisticsCompany" type="info" size="small" class="tracking-company">
  38 + {{ waybillLogisticsCompany }}
  39 + </el-tag>
  40 + <span class="tracking-no" :title="waybillTrackingNumber">{{ waybillTrackingNumber }}</span>
  41 + <el-button link type="primary" size="small" @click="copySingleOrderId(waybillTrackingNumber)" class="copy-btn">复制</el-button>
  42 + <el-tag
  43 + v-if="waybillStatus === 2"
  44 + type="success"
  45 + size="small"
  46 + class="tracking-shipped"
  47 + >已发货</el-tag>
  48 + <span v-if="waybillShipTime" class="tracking-time">{{ formatShipTime(waybillShipTime) }}</span>
  49 + </div>
  50 + </el-form-item>
34 51 <el-form-item label="订单号">
35   - <div class="order-id-copy">
36   - <span class="order-id-text" :title="form.orderId_Douyin">{{ form.orderId_Douyin }}</span>
37   - <el-button link type="primary" size="small" @click="copyOrderId" class="copy-btn">复制</el-button>
  52 + <div class="order-id-wrap">
  53 + <!-- 合并单:逐行显示每个原始订单号 + 独立复制;单订单保持一行 -->
  54 + <div
  55 + v-for="(oid, idx) in orderIdList"
  56 + :key="oid + '_' + idx"
  57 + class="order-id-row"
  58 + >
  59 + <span class="order-id-index" v-if="orderIdList.length > 1">{{ idx + 1 }}</span>
  60 + <span class="order-id-text" :title="oid">{{ oid }}</span>
  61 + <el-button link type="primary" size="small" @click="copySingleOrderId(oid)" class="copy-btn">复制</el-button>
  62 + </div>
  63 + <!-- 合并单额外给一个「全部复制」 -->
  64 + <div v-if="orderIdList.length > 1" class="order-id-all">
  65 + <el-button link type="success" size="small" @click="copyAllOrderIds">全部复制({{ orderIdList.length }})</el-button>
  66 + </div>
  67 + <span v-if="orderIdList.length === 0" style="color:#c0c4cc;">无</span>
38 68 </div>
39 69 </el-form-item>
40 70 <el-form-item label="订单状态">
... ... @@ -74,21 +104,41 @@
74 104 </el-alert>
75 105 </el-form-item>
76 106 <el-row :gutter="8">
77   - <el-col :span="8">
78   - <el-form-item label="订单金额" label-width="70px">
  107 + <el-col :span="12">
  108 + <el-form-item label="订单金额" label-width="90px">
79 109 <span class="amount-text">{{ formatOrderMoney(form.orderAmountFen) }}</span>
80 110 </el-form-item>
81 111 </el-col>
82   - <el-col :span="8">
83   - <el-form-item label="实付金额" label-width="70px">
  112 + <el-col :span="12">
  113 + <el-form-item label="用户支付金额" label-width="90px">
84 114 <span class="amount-text amount-pay">{{ formatOrderMoney(form.payAmountFen) }}</span>
85 115 </el-form-item>
86 116 </el-col>
87   - <el-col :span="8">
88   - <el-form-item label="优惠金额" label-width="70px">
  117 + <el-col :span="12">
  118 + <el-form-item label="优惠金额" label-width="90px">
89 119 <span class="amount-text amount-discount">{{ formatDiscountFen(form.orderAmountFen, form.payAmountFen) }}</span>
90 120 </el-form-item>
91 121 </el-col>
  122 + <el-col :span="12">
  123 + <el-form-item label="商家实际收入" label-width="90px">
  124 + <el-input-number
  125 + v-model="form.merchantIncome"
  126 + :min="0"
  127 + :precision="2"
  128 + :step="1"
  129 + :controls="false"
  130 + size="small"
  131 + style="width: 140px;"
  132 + :disabled="(waybillStatus !== undefined && waybillStatus >= 1) || form.status === 2 || form.status === 3 || form.status === 4"
  133 + />
  134 + <el-tooltip
  135 + content="默认等于用户支付金额,可修改;提交后写入 ERP 销售出库单的收款金额"
  136 + placement="top"
  137 + >
  138 + <el-icon style="margin-left: 4px; color: #909399; vertical-align: middle;"><InfoFilled /></el-icon>
  139 + </el-tooltip>
  140 + </el-form-item>
  141 + </el-col>
92 142 </el-row>
93 143 </el-col>
94 144 <!-- 右列:商品明细 -->
... ... @@ -100,15 +150,16 @@
100 150 :key="index"
101 151 class="order-product-item"
102 152 >
  153 + <!-- 商品明细:显示抖音图,没有则显示"暂无图片" -->
103 154 <el-image
104   - v-if="item.product_pic"
105   - :src="item.product_pic"
  155 + v-if="item.douyin_pic || item.product_pic"
  156 + :src="item.douyin_pic || item.product_pic"
106 157 class="product-thumb"
107 158 fit="cover"
108   - :preview-src-list="[item.product_pic]"
  159 + :preview-src-list="[item.douyin_pic || item.product_pic]"
109 160 preview-teleported
110 161 />
111   - <div class="product-thumb-placeholder" v-else />
  162 + <div class="product-thumb-placeholder" v-else>暂无图片</div>
112 163 <div class="product-info">
113 164 <div class="product-name">{{ item.product_name }}</div>
114 165 <div v-if="getSkuCode(item)" class="product-sku">
... ... @@ -167,6 +218,16 @@
167 218 <div class="section-header">
168 219 <span class="section-title">商品清单</span>
169 220 <div class="product-header-selects">
  221 + <el-form-item label="发货人" required class="inline-select">
  222 + <el-select v-model="form.fhr" placeholder="请选择" clearable filterable size="small">
  223 + <el-option
  224 + v-for="u in userOptions"
  225 + :key="u.id"
  226 + :label="u.displayName || u.realName || u.account || u.id"
  227 + :value="u.id"
  228 + />
  229 + </el-select>
  230 + </el-form-item>
170 231 <el-form-item label="出库仓库" required class="inline-select">
171 232 <el-select v-model="form.cjck" placeholder="请选择" clearable size="small">
172 233 <el-option v-for="item in warehouseOptions" :key="item.id || item.F_Id" :label="item.mdmc || item.F_mdmc || item.Mdmc" :value="item.id || item.F_Id" />
... ... @@ -179,7 +240,7 @@
179 240 </el-form-item>
180 241 <el-form-item label="收款账户" required class="inline-select">
181 242 <el-select v-model="form.skzh" placeholder="请选择" clearable filterable size="small" :disabled="shopSettingsLocked.skzh">
182   - <el-option v-for="item in paymentAccountOptions" :key="item.skzhDictId || item.id || item.F_Id" :label="item.zhmc || item.F_zhmc || item.Zhmc" :value="item.skzhDictId || item.id || item.F_Id" />
  243 + <el-option v-for="item in paymentAccountOptions" :key="item.id" :label="item.accountName || item.zhmc || item.id" :value="item.id" />
183 244 </el-select>
184 245 </el-form-item>
185 246 <el-button type="primary" size="small" @click="showProductDialog = true">添加ERP商品</el-button>
... ... @@ -199,16 +260,17 @@
199 260 >
200 261 <el-table-column label="商品图片" width="80" align="center">
201 262 <template #default="{ row }">
  263 + <!-- 商品清单:显示 ERP 图,没有则显示"暂无图片" -->
202 264 <el-image
203   - v-if="row.product_pic"
204   - :src="row.product_pic"
  265 + v-if="row.erp_pic"
  266 + :src="row.erp_pic"
205 267 style="width: 50px; height: 50px; border-radius: 4px;"
206 268 fit="cover"
207   - :preview-src-list="[row.product_pic]"
  269 + :preview-src-list="[row.erp_pic]"
208 270 preview-teleported
209 271 lazy
210 272 />
211   - <span v-else style="color: #c0c4cc; font-size: 12px;">无图片</span>
  273 + <div v-else class="cell-no-image">暂无图片</div>
212 274 </template>
213 275 </el-table-column>
214 276 <el-table-column prop="product_name" label="商品名称" min-width="180" show-overflow-tooltip />
... ... @@ -281,31 +343,59 @@
281 343 </div>
282 344 </template>
283 345 </el-table-column>
284   - <el-table-column label="选择序列号" width="150" align="center">
  346 + <el-table-column label="选择序列号" width="170" align="center">
285 347 <template #default="{ row }">
286 348 <div class="serial-number-selector">
287   - <div v-if="row.selectedSerialNumbers && row.selectedSerialNumbers.length > 0" class="selected-serial-numbers">
288   - <el-tag
289   - v-for="(serialNumber, index) in row.selectedSerialNumbers.slice(0, 2)"
290   - :key="index"
291   - size="small"
292   - closable
293   - @close="removeSerialNumber(row, serialNumber)"
294   - style="margin: 2px;"
295   - >
296   - {{ serialNumber }}
297   - </el-tag>
298   - <el-tag v-if="row.selectedSerialNumbers.length > 2" size="small" type="info">
299   - +{{ row.selectedSerialNumbers.length - 2 }}
300   - </el-tag>
301   - </div>
302   - <el-button
303   - type="primary"
304   - size="small"
  349 + <!-- 已选状态徽标:悬停或点击查看完整列表,不在清单行内铺 tag -->
  350 + <el-popover
  351 + v-if="row.selectedSerialNumbers && row.selectedSerialNumbers.length > 0"
  352 + placement="top"
  353 + :width="320"
  354 + trigger="click"
  355 + popper-class="sn-popover"
  356 + >
  357 + <template #reference>
  358 + <el-tag
  359 + :type="getSerialCountTagType(row)"
  360 + size="small"
  361 + effect="light"
  362 + class="sn-count-tag"
  363 + >
  364 + 已选 {{ row.selectedSerialNumbers.length }}
  365 + <span v-if="Number(row.item_num) > 0"> / {{ row.item_num }}</span>
  366 + </el-tag>
  367 + </template>
  368 +
  369 + <div class="sn-popover-header">
  370 + <span>已选序列号 ({{ row.selectedSerialNumbers.length }})</span>
  371 + <el-button
  372 + type="danger"
  373 + link
  374 + size="small"
  375 + @click="clearSerialNumbers(row)"
  376 + >清空</el-button>
  377 + </div>
  378 + <div class="sn-popover-list">
  379 + <el-tag
  380 + v-for="(sn, idx) in row.selectedSerialNumbers"
  381 + :key="idx"
  382 + size="small"
  383 + closable
  384 + @close="removeSerialNumber(row, sn)"
  385 + class="sn-tag"
  386 + >
  387 + {{ sn }}
  388 + </el-tag>
  389 + </div>
  390 + </el-popover>
  391 +
  392 + <el-button
  393 + type="primary"
  394 + size="small"
305 395 @click="openSerialNumberSelect(row)"
306 396 :disabled="!row.spbm && !getSkuCode(row)"
307 397 >
308   - 选择序列号
  398 + {{ row.selectedSerialNumbers && row.selectedSerialNumbers.length > 0 ? '修改序列号' : '选择序列号' }}
309 399 </el-button>
310 400 </div>
311 401 </template>
... ... @@ -435,8 +525,8 @@
435 525 import { ref, computed, onMounted, nextTick } from 'vue'
436 526 import { useRoute, useRouter } from 'vue-router'
437 527 import { ElMessage, ElMessageBox } from 'element-plus'
438   -import { Refresh } from '@element-plus/icons-vue'
439   -import { getOrderDetail, getMergedOrderDetail, searchProducts as searchProductsAPI, manualCreateWaybill, getProductInfo as getProductInfoAPI, getWarehouses, getProductCategories, updateSellerRemark, getDefaults, getCustomers, getPaymentAccounts, getShopSettings, checkStock } from '@/api/order'
  528 +import { Refresh, InfoFilled } from '@element-plus/icons-vue'
  529 +import { getOrderDetail, getMergedOrderDetail, searchProducts as searchProductsAPI, manualCreateWaybill, getProductInfo as getProductInfoAPI, getWarehouses, getProductCategories, updateSellerRemark, getDefaults, getCustomers, getPaymentAccounts, getUsers, getShopSettings, checkStock } from '@/api/order'
440 530 import { getErpBaseUrl } from '@/utils/config'
441 531 import SerialNumberSelect from '@/components/SerialNumberSelect.vue'
442 532  
... ... @@ -450,6 +540,8 @@ const orderId = ref&lt;number&gt;(Number(route.params.id || 0))
450 540 const mergedInternalIds = ref<number[]>([])
451 541 const tableKey = ref(0) // 用于强制更新表格
452 542 const serialNumberSelectRef = ref()
  543 +// 当前打开序列号弹窗对应的商品行引用(选完序列号后直接写回这一行,避免 spbm 被弹窗改动导致匹配错位)
  544 +const currentSerialRow = ref<any | null>(null)
453 545  
454 546 // 商品信息缓存(用于存储序列号类型)
455 547 const productCache = new Map<string, any>()
... ... @@ -481,20 +573,40 @@ const formatDiscountFen = (orderFen: number | null | undefined, payFen: number |
481 573 return `¥${(d / 100).toFixed(2)}`
482 574 }
483 575  
484   -const copyOrderId = () => {
485   - const id = form.value.orderId_Douyin
486   - if (!id) return
487   - navigator.clipboard.writeText(id).then(() => {
488   - ElMessage.success('订单号已复制')
489   - }).catch(() => {
  576 +// 订单号列表:合并单时后端以逗号拼接(OrdersController.GetMergedOrderDetail 里 string.Join(",", ...))
  577 +const orderIdList = computed<string[]>(() => {
  578 + const raw = String(form.value.orderId_Douyin || '').trim()
  579 + if (!raw) return []
  580 + return raw.split(/[,\s,;;|]+/).map(s => s.trim()).filter(Boolean)
  581 +})
  582 +
  583 +const copyTextToClipboard = async (text: string) => {
  584 + if (!text) return
  585 + try {
  586 + await navigator.clipboard.writeText(text)
  587 + } catch {
490 588 const ta = document.createElement('textarea')
491   - ta.value = id
  589 + ta.value = text
492 590 document.body.appendChild(ta)
493 591 ta.select()
494 592 document.execCommand('copy')
495 593 document.body.removeChild(ta)
496   - ElMessage.success('订单号已复制')
497   - })
  594 + }
  595 +}
  596 +
  597 +// 单个订单号复制
  598 +const copySingleOrderId = async (id: string) => {
  599 + if (!id) return
  600 + await copyTextToClipboard(id)
  601 + ElMessage.success(`订单号 ${id} 已复制`)
  602 +}
  603 +
  604 +// 全部订单号复制(逗号分隔,方便粘贴到 Excel)
  605 +const copyAllOrderIds = async () => {
  606 + const all = orderIdList.value.join(',')
  607 + if (!all) return
  608 + await copyTextToClipboard(all)
  609 + ElMessage.success(`已复制 ${orderIdList.value.length} 个订单号`)
498 610 }
499 611  
500 612 const form = ref({
... ... @@ -522,7 +634,11 @@ const form = ref({
522 634 kh: '',
523 635 skzh: '',
524 636 productItems: [] as any[],
525   - remark: ''
  637 + remark: '',
  638 + /** 商家实际收入(元)。默认 = 用户支付金额(元),用户可改;最终写入 ERP 销售出库单的收款金额 */
  639 + merchantIncome: null as number | null,
  640 + /** 发货人(ERP 用户 Id),必填;写入 ERP 销售出库单 fhr */
  641 + fhr: ''
526 642 })
527 643  
528 644 // 订单备注
... ... @@ -534,12 +650,27 @@ const remarkSyncing = ref(false)
534 650 const warehouseOptions = ref<any[]>([])
535 651 const customerOptions = ref<any[]>([])
536 652 const paymentAccountOptions = ref<any[]>([])
  653 +// 发货人(ERP 用户)选项
  654 +const userOptions = ref<{ id: string; realName?: string; account?: string; displayName?: string }[]>([])
537 655  
538 656 /** 往来单位/收款账户是否由店铺设置锁定(不可改) */
539 657 const shopSettingsLocked = ref({ kh: false, skzh: false })
540 658  
541 659 // 发货单状态(0-待打印,1-已打印,2-已发货)
542 660 const waybillStatus = ref<number | undefined>(undefined)
  661 +// 已发货时回显的物流信息
  662 +const waybillTrackingNumber = ref('')
  663 +const waybillLogisticsCompany = ref('')
  664 +const waybillShipTime = ref<string>('')
  665 +
  666 +// 格式化发货时间
  667 +const formatShipTime = (val: string): string => {
  668 + if (!val) return ''
  669 + const d = new Date(val)
  670 + if (isNaN(d.getTime())) return String(val)
  671 + const pad = (n: number) => n.toString().padStart(2, '0')
  672 + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
  673 +}
543 674  
544 675 // 商品选择对话框
545 676 const showProductDialog = ref(false)
... ... @@ -594,6 +725,18 @@ const loadPaymentAccounts = async () =&gt; {
594 725 }
595 726 }
596 727  
  728 +// 加载 ERP 用户列表(发货人下拉)
  729 +const loadUsers = async () => {
  730 + try {
  731 + const response = await getUsers()
  732 + if (response.data && response.data.code === 200) {
  733 + userOptions.value = response.data.data || []
  734 + }
  735 + } catch (error: any) {
  736 + console.error('加载用户列表失败:', error)
  737 + }
  738 +}
  739 +
597 740 // 加载订单详情(支持合并订单)
598 741 const loadOrderDetail = async () => {
599 742 try {
... ... @@ -648,13 +791,39 @@ const loadOrderDetail = async () =&gt; {
648 791 if (data.waybill.status !== undefined) {
649 792 waybillStatus.value = data.waybill.status
650 793 }
  794 + // 已发货单的物流信息回显
  795 + waybillTrackingNumber.value = data.waybill.trackingNumber || ''
  796 + waybillLogisticsCompany.value = data.waybill.logisticsCompany || ''
  797 + waybillShipTime.value = data.waybill.shipTime || ''
  798 + if (data.waybill.logisticsCompany) {
  799 + form.value.logisticsCompany = data.waybill.logisticsCompany
  800 + }
  801 + // 发货人:有历史值就回显,没有等待 loadUsers 完成后也不主动默认(由用户选择)
  802 + if (data.waybill.fhr) {
  803 + form.value.fhr = data.waybill.fhr
  804 + }
  805 + // 商家实际收入:有已保存值就回显,否则默认等于用户支付金额(元)
  806 + if (data.waybill.merchantIncome !== null && data.waybill.merchantIncome !== undefined && data.waybill.merchantIncome !== '') {
  807 + form.value.merchantIncome = Number(data.waybill.merchantIncome)
  808 + } else {
  809 + form.value.merchantIncome = form.value.payAmountFen != null
  810 + ? Number((form.value.payAmountFen / 100).toFixed(2))
  811 + : 0
  812 + }
651 813 } else {
652 814 waybillStatus.value = undefined
  815 + waybillTrackingNumber.value = ''
  816 + waybillLogisticsCompany.value = ''
  817 + waybillShipTime.value = ''
  818 + // 新发货单:默认商家实际收入 = 用户支付金额(元)
  819 + form.value.merchantIncome = form.value.payAmountFen != null
  820 + ? Number((form.value.payAmountFen / 100).toFixed(2))
  821 + : 0
653 822 }
654 823  
655 824 shopSettingsLocked.value = { kh: false, skzh: false }
656 825  
657   - // 按店铺获取专属 kh/skzh
  826 + // 按店铺获取专属 cjck/kh/skzh(抖音发货单的出库仓库只来自店铺设置,不再取 ERP 全局默认)
658 827 const shopId = data.order.shopId
659 828 if (shopId) {
660 829 try {
... ... @@ -663,6 +832,10 @@ const loadOrderDetail = async () =&gt; {
663 832 console.log('[店铺设置] 响应:', JSON.stringify(shopRes.data))
664 833 const shopData = shopRes.data?.data
665 834 if (shopData) {
  835 + if (shopData.ck && !form.value.cjck) {
  836 + form.value.cjck = shopData.ck
  837 + console.log('[店铺设置] ck =', shopData.ck)
  838 + }
666 839 if (shopData.kh) {
667 840 form.value.kh = shopData.kh
668 841 shopSettingsLocked.value.kh = true
... ... @@ -677,13 +850,13 @@ const loadOrderDetail = async () =&gt; {
677 850 console.warn('[店铺设置] shopData 为空,响应体:', shopRes.data)
678 851 }
679 852 } catch (e) {
680   - console.warn('[店铺设置] 获取失败,将使用全局默认', e)
  853 + console.warn('[店铺设置] 获取失败', e)
681 854 }
682 855 } else {
683 856 console.warn('[店铺设置] 订单无 shopId,跳过店铺设置加载')
684 857 }
685 858  
686   - // 全局默认设置兜底(出库仓库 / 往来单位 / 收款账户)
  859 + // 全局默认兜底(出库仓库 / 往来单位 / 收款账户)
687 860 try {
688 861 const defaultsRes = await getDefaults()
689 862 const defaults = defaultsRes.data
... ... @@ -715,10 +888,18 @@ const loadOrderDetail = async () =&gt; {
715 888 }
716 889  
717 890 // 尝试多种可能的字段名
  891 + // 图片拆两个字段:
  892 + // douyin_pic = 抖音原图(用于"商品明细")
  893 + // erp_pic = ERP 图(用于"商品清单";由 loadSerialNumberType 匹配 ERP 商品后填充)
  894 + // 兼容老发货单:若保存的老数据里只有 product_pic,无法再区分,统一作为 douyin_pic 兜底
718 895 const rawPic = item.product_pic || item.ProductPic || item.pic || item.image || ''
  896 + const rawDouyinPic = item.douyin_pic || item.DouyinPic || ''
  897 + const rawErpPic = item.erp_pic || item.ErpPic || ''
719 898 const mappedItem = {
720 899 product_name: item.product_name || item.ProductName || item.spmc || item.Spmc || item.name || '',
721 900 product_pic: resolveProductPicUrl(rawPic),
  901 + douyin_pic: resolveProductPicUrl(rawDouyinPic || rawPic),
  902 + erp_pic: resolveProductPicUrl(rawErpPic),
722 903 item_num: item.item_num || item.ItemNum || item.itemNum || item.quantity || item.num || 1,
723 904 goods_price: goodsPrice,
724 905 spec: item.spec || item.Spec || item.gg || item.Gg || item.specification || '',
... ... @@ -821,6 +1002,9 @@ const addProduct = async (product: any) =&gt; {
821 1002 const newProduct = {
822 1003 product_name: product.spmc || '',
823 1004 product_pic: resolveProductPicUrl(productPic || product.imageUrl || ''),
  1005 + // ERP 添加的商品没有抖音图,ERP 图直接填 erp_pic,用于"商品清单"展示
  1006 + douyin_pic: '',
  1007 + erp_pic: resolveProductPicUrl(productPic || product.imageUrl || ''),
824 1008 spbm: product.spbm || '',
825 1009 sku_id: product.dyspid || '',
826 1010 item_num: 1,
... ... @@ -917,8 +1101,9 @@ const loadSerialNumberType = async (item: any) =&gt; {
917 1101 if (bySku.spxlhType !== undefined && bySku.spxlhType !== null && String(bySku.spxlhType) !== '') {
918 1102 item.spxlhType = String(bySku.spxlhType)
919 1103 }
  1104 + // ERP 图片写到 erp_pic,不覆盖抖音原图 douyin_pic
920 1105 if (bySku.imageUrl) {
921   - item.product_pic = resolveProductPicUrl(bySku.imageUrl)
  1106 + item.erp_pic = resolveProductPicUrl(bySku.imageUrl)
922 1107 }
923 1108 }
924 1109 }
... ... @@ -929,7 +1114,7 @@ const loadSerialNumberType = async (item: any) =&gt; {
929 1114 if (response.data?.data?.length > 0) {
930 1115 const product = response.data.data[0]
931 1116 if (product.spbm) item.spbm = product.spbm
932   - if (product.imageUrl) item.product_pic = resolveProductPicUrl(product.imageUrl)
  1117 + if (product.imageUrl) item.erp_pic = resolveProductPicUrl(product.imageUrl)
933 1118 }
934 1119 } catch { /* ignore */ }
935 1120 }
... ... @@ -941,7 +1126,7 @@ const loadSerialNumberType = async (item: any) =&gt; {
941 1126 item.spxlhType = String(product.spxlhType)
942 1127 }
943 1128 if (product.imageUrl) {
944   - item.product_pic = resolveProductPicUrl(product.imageUrl)
  1129 + item.erp_pic = resolveProductPicUrl(product.imageUrl)
945 1130 }
946 1131 }
947 1132 }
... ... @@ -1045,37 +1230,38 @@ const getProductCodeForSerialNumber = async (row: any): Promise&lt;string | null&gt; =
1045 1230 const openSerialNumberSelect = async (row: any) => {
1046 1231 // 获取商品编码(可能通过sku_id查找)
1047 1232 const productCode = await getProductCodeForSerialNumber(row)
1048   -
  1233 +
1049 1234 if (!productCode) {
1050 1235 ElMessage.warning('无法获取商品编码,请确保商品已关联ERP系统')
1051 1236 return
1052 1237 }
1053   -
  1238 +
  1239 + // 记住当前操作的行,选完序列号直接写回这一行
  1240 + currentSerialRow.value = row
  1241 +
  1242 + // 把商品清单里的需求数量传进去,用于弹窗内限制勾选上限
  1243 + const need = Number(row.item_num) || 0
1054 1244 serialNumberSelectRef.value?.open(
1055 1245 productCode,
1056 1246 form.value.cjck || '',
1057 1247 row.selectedSerialNumbers || [],
1058   - '发货单'
  1248 + '发货单',
  1249 + need
1059 1250 )
1060 1251 }
1061 1252  
1062 1253 // 处理序列号选择
1063 1254 const handleSerialNumberSelect = (selectedSerialNumbers: string[]) => {
1064   - const currentProductCode = serialNumberSelectRef.value?.currentProductCode
1065   - const currentRow = form.value.productItems.find((item: any) => item.spbm === currentProductCode)
1066   -
1067   - if (!currentRow && currentProductCode) {
1068   - for (const item of form.value.productItems) {
1069   - if (!item.spbm && getSkuCode(item)) {
1070   - item.spbm = currentProductCode
1071   - item.selectedSerialNumbers = selectedSerialNumbers
1072   - const count = selectedSerialNumbers.length
1073   - if (Number(item.item_num) !== count) {
1074   - item.item_num = count
1075   - ElMessage.info(`商品"${item.product_name}"的数量已自动更新为 ${count}`)
1076   - }
1077   - return
1078   - }
  1255 + // 优先使用打开弹窗时保存的行引用,最稳妥
  1256 + let currentRow = currentSerialRow.value
  1257 +
  1258 + // 兼容老逻辑:如果引用丢失(例如表格重建),再退回 productCode 匹配
  1259 + if (!currentRow) {
  1260 + const currentProductCode = serialNumberSelectRef.value?.currentProductCode
  1261 + if (currentProductCode) {
  1262 + currentRow = form.value.productItems.find((item: any) =>
  1263 + item.spbm === currentProductCode || getSkuCode(item) === currentProductCode
  1264 + ) || null
1079 1265 }
1080 1266 }
1081 1267  
... ... @@ -1086,7 +1272,12 @@ const handleSerialNumberSelect = (selectedSerialNumbers: string[]) =&gt; {
1086 1272 currentRow.item_num = count
1087 1273 ElMessage.info(`商品"${currentRow.product_name}"的数量已自动更新为 ${count}`)
1088 1274 }
  1275 + } else {
  1276 + ElMessage.warning('未能匹配到需要更新的商品行,请重新打开序列号弹窗')
1089 1277 }
  1278 +
  1279 + // 关闭后清空,避免串行到下一行
  1280 + currentSerialRow.value = null
1090 1281 }
1091 1282  
1092 1283 // 移除序列号
... ... @@ -1099,6 +1290,22 @@ const removeSerialNumber = (row: any, serialNumber: string) =&gt; {
1099 1290 }
1100 1291 }
1101 1292  
  1293 +// 清空某行全部已选序列号
  1294 +const clearSerialNumbers = (row: any) => {
  1295 + if (!row) return
  1296 + row.selectedSerialNumbers = []
  1297 +}
  1298 +
  1299 +// 根据"已选数量 vs 需求数量"计算徽标颜色:不够→警告,刚好→成功,超了→红色
  1300 +const getSerialCountTagType = (row: any): 'success' | 'warning' | 'danger' | 'info' => {
  1301 + const need = Number(row?.item_num) || 0
  1302 + const got = row?.selectedSerialNumbers?.length || 0
  1303 + if (!need) return 'info'
  1304 + if (got === need) return 'success'
  1305 + if (got > need) return 'danger'
  1306 + return 'warning'
  1307 +}
  1308 +
1102 1309 // 获取SKU编码(优先使用sku_id,和订单列表保持一致)
1103 1310 const getSkuCode = (item: any): string => {
1104 1311 if (!item) return ''
... ... @@ -1230,6 +1437,7 @@ const handleSubmit = async () =&gt; {
1230 1437 return
1231 1438 }
1232 1439  
  1440 + if (!form.value.fhr) { ElMessage.warning('请选择发货人'); return }
1233 1441 if (!form.value.cjck) { ElMessage.warning('请选择出库仓库'); return }
1234 1442 if (!form.value.kh) { ElMessage.warning('请选择往来单位'); return }
1235 1443 if (!form.value.skzh) { ElMessage.warning('请选择收款账户'); return }
... ... @@ -1269,13 +1477,13 @@ const handleSubmit = async () =&gt; {
1269 1477  
1270 1478 try {
1271 1479 await ElMessageBox.confirm(
1272   - '确定提交发货单吗?系统将自动校验库存,库存充足时生成「销售出库单」,库存不足时自动生成「预售出库单」。',
  1480 + '确定提交发货单吗?系统会校验库存,库存充足时生成「销售出库单」;库存不足将直接拒绝发货,请先到 ERP 补充库存。',
1273 1481 '提示',
1274 1482 { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
1275 1483 )
1276 1484 } catch { return }
1277 1485  
1278   - // 库存检查
  1486 + // 库存检查:库存不足直接拒绝,不再转预售出库单
1279 1487 if (form.value.cjck && form.value.productItems.length > 0) {
1280 1488 try {
1281 1489 const stockItems = form.value.productItems
... ... @@ -1286,7 +1494,6 @@ const handleSubmit = async () =&gt; {
1286 1494 const stockRes = await checkStock(form.value.cjck, stockItems)
1287 1495 const stockData = stockRes.data
1288 1496  
1289   - // 检查是否有库存不足的商品
1290 1497 const insufficientItems = stockData?.data?.filter?.((item: any) => !item.sufficient) || []
1291 1498  
1292 1499 if (insufficientItems.length > 0) {
... ... @@ -1294,21 +1501,12 @@ const handleSubmit = async () =&gt; {
1294 1501 `${item.spmc || item.spbm}(库存: ${item.stock ?? 0},需要: ${item.quantity})`
1295 1502 ).join('\n')
1296 1503  
1297   - try {
1298   - await ElMessageBox.confirm(
1299   - `以下商品库存不足:\n\n${itemNames}\n\n选择「继续发货」将自动生成预售出库单。`,
1300   - '库存不足',
1301   - {
1302   - confirmButtonText: '继续发货(转预售出库单)',
1303   - cancelButtonText: '取消发货',
1304   - type: 'warning',
1305   - distinguishCancelAndClose: true
1306   - }
1307   - )
1308   - // 用户选择继续 → 不做额外处理,后端 CreateSalesOrderAsync 的 BatchCheckStock 会自动转预售
1309   - } catch {
1310   - return // 用户取消
1311   - }
  1504 + await ElMessageBox.alert(
  1505 + `以下商品库存不足,无法发货:\n\n${itemNames}\n\n请先到 ERP 补充库存后再提交发货单。`,
  1506 + '库存不足',
  1507 + { confirmButtonText: '我知道了', type: 'error' }
  1508 + )
  1509 + return
1312 1510 }
1313 1511 }
1314 1512 } catch (e) {
... ... @@ -1320,7 +1518,10 @@ const handleSubmit = async () =&gt; {
1320 1518 try {
1321 1519 const productItemsForSubmit = form.value.productItems.map((item: any) => ({
1322 1520 product_name: item.product_name || '',
1323   - product_pic: item.product_pic || '',
  1521 + // product_pic 保留以兼容老后端;同时新增 douyin_pic/erp_pic 分别存抖音图和 ERP 图
  1522 + product_pic: item.erp_pic || item.product_pic || item.douyin_pic || '',
  1523 + douyin_pic: item.douyin_pic || '',
  1524 + erp_pic: item.erp_pic || '',
1324 1525 item_num: Number(item.item_num) || 1,
1325 1526 goods_price: Math.round((Number(item.goods_price) || 0) * 100),
1326 1527 spec: item.spec || '',
... ... @@ -1352,7 +1553,9 @@ const handleSubmit = async () =&gt; {
1352 1553 productItems: productItemsForSubmit,
1353 1554 remark: form.value.remark,
1354 1555 douyinOrderIds: form.value.orderId_Douyin || '',
1355   - mergedOrderInternalIds: mergedInternalIds.value.length > 1 ? mergedInternalIds.value : undefined
  1556 + mergedOrderInternalIds: mergedInternalIds.value.length > 1 ? mergedInternalIds.value : undefined,
  1557 + merchantIncome: form.value.merchantIncome,
  1558 + fhr: form.value.fhr
1356 1559 })
1357 1560  
1358 1561 ElMessage.success(response.data.message || '发货单创建成功')
... ... @@ -1376,6 +1579,7 @@ onMounted(() =&gt; {
1376 1579 loadWarehouses()
1377 1580 loadCustomers()
1378 1581 loadPaymentAccounts()
  1582 + loadUsers()
1379 1583 loadOrderDetail()
1380 1584 })
1381 1585 </script>
... ... @@ -1492,11 +1696,33 @@ onMounted(() =&gt; {
1492 1696 }
1493 1697  
1494 1698 /* ── 订单号复制 ── */
1495   -.order-id-copy {
  1699 +.order-id-wrap {
  1700 + display: flex;
  1701 + flex-direction: column;
  1702 + gap: 2px;
  1703 + min-height: 28px;
  1704 +}
  1705 +
  1706 +.order-id-row {
1496 1707 display: flex;
1497 1708 align-items: center;
1498 1709 gap: 6px;
1499   - min-height: 28px;
  1710 + line-height: 22px;
  1711 +}
  1712 +
  1713 +.order-id-index {
  1714 + display: inline-flex;
  1715 + align-items: center;
  1716 + justify-content: center;
  1717 + min-width: 18px;
  1718 + height: 18px;
  1719 + padding: 0 4px;
  1720 + background: #ecf5ff;
  1721 + color: #409eff;
  1722 + border-radius: 9px;
  1723 + font-size: 11px;
  1724 + font-weight: 600;
  1725 + flex-shrink: 0;
1500 1726 }
1501 1727  
1502 1728 .order-id-text {
... ... @@ -1507,12 +1733,47 @@ onMounted(() =&gt; {
1507 1733 word-break: break-all;
1508 1734 }
1509 1735  
  1736 +.order-id-all {
  1737 + margin-top: 2px;
  1738 +}
  1739 +
1510 1740 .copy-btn {
1511 1741 flex-shrink: 0;
1512 1742 font-size: 12px;
1513 1743 padding: 0 4px;
1514 1744 }
1515 1745  
  1746 +/* ── 物流单号回显 ── */
  1747 +.tracking-wrap {
  1748 + display: flex;
  1749 + align-items: center;
  1750 + flex-wrap: wrap;
  1751 + gap: 6px;
  1752 + min-height: 28px;
  1753 +}
  1754 +
  1755 +.tracking-company {
  1756 + flex-shrink: 0;
  1757 +}
  1758 +
  1759 +.tracking-no {
  1760 + font-size: 13px;
  1761 + font-weight: 600;
  1762 + color: #303133;
  1763 + letter-spacing: 0.2px;
  1764 + user-select: text;
  1765 + word-break: break-all;
  1766 +}
  1767 +
  1768 +.tracking-shipped {
  1769 + flex-shrink: 0;
  1770 +}
  1771 +
  1772 +.tracking-time {
  1773 + font-size: 12px;
  1774 + color: #909399;
  1775 +}
  1776 +
1516 1777 /* ── 金额 ── */
1517 1778 .amount-text {
1518 1779 font-size: 13px;
... ... @@ -1574,6 +1835,30 @@ onMounted(() =&gt; {
1574 1835 flex-shrink: 0;
1575 1836 background: #f5f7fa;
1576 1837 border: 1px dashed #dcdfe6;
  1838 + display: flex;
  1839 + align-items: center;
  1840 + justify-content: center;
  1841 + color: #c0c4cc;
  1842 + font-size: 11px;
  1843 + text-align: center;
  1844 + line-height: 1.2;
  1845 +}
  1846 +
  1847 +/* 商品清单单元格内的"暂无图片"占位 */
  1848 +.cell-no-image {
  1849 + width: 50px;
  1850 + height: 50px;
  1851 + margin: 0 auto;
  1852 + border-radius: 4px;
  1853 + background: #f5f7fa;
  1854 + border: 1px dashed #dcdfe6;
  1855 + display: flex;
  1856 + align-items: center;
  1857 + justify-content: center;
  1858 + color: #c0c4cc;
  1859 + font-size: 11px;
  1860 + text-align: center;
  1861 + line-height: 1.2;
1577 1862 }
1578 1863  
1579 1864 .product-info {
... ... @@ -1670,16 +1955,19 @@ onMounted(() =&gt; {
1670 1955  
1671 1956 /* ── 序列号 ── */
1672 1957 .serial-number-selector {
  1958 + display: flex;
  1959 + flex-direction: column;
  1960 + align-items: center;
  1961 + gap: 4px;
1673 1962 min-height: 30px;
1674 1963 }
1675 1964  
1676   -.selected-serial-numbers {
1677   - display: flex;
1678   - flex-wrap: wrap;
1679   - gap: 2px;
1680   - margin-bottom: 4px;
  1965 +.sn-count-tag {
  1966 + cursor: pointer;
  1967 + user-select: none;
1681 1968 }
1682 1969  
  1970 +
1683 1971 /* ── 滚动条 ── */
1684 1972 .order-product-list::-webkit-scrollbar {
1685 1973 width: 5px;
... ... @@ -1705,3 +1993,29 @@ onMounted(() =&gt; {
1705 1993 }
1706 1994 </style>
1707 1995  
  1996 +<!-- 非 scoped:el-popover teleport 到 body,scoped 选择器命中不到 -->
  1997 +<style>
  1998 +.sn-popover .sn-popover-header {
  1999 + display: flex;
  2000 + justify-content: space-between;
  2001 + align-items: center;
  2002 + margin-bottom: 8px;
  2003 + font-weight: 600;
  2004 + color: #303133;
  2005 + font-size: 13px;
  2006 +}
  2007 +
  2008 +.sn-popover .sn-popover-list {
  2009 + max-height: 240px;
  2010 + overflow-y: auto;
  2011 + display: flex;
  2012 + flex-wrap: wrap;
  2013 + gap: 4px;
  2014 + padding: 2px;
  2015 +}
  2016 +
  2017 +.sn-popover .sn-tag {
  2018 + max-width: 100%;
  2019 +}
  2020 +</style>
  2021 +
... ...
Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue
... ... @@ -171,7 +171,7 @@
171 171 @selection-change="handleSelectionChange"
172 172 @sort-change="handleSortChange"
173 173 >
174   - <el-table-column type="selection" width="55" :selectable="(row: Order) => (row.status === 0 || row.status === 1) && !(row.mergedOrderIds && row.mergedOrderIds.length > 1)" />
  174 + <el-table-column type="selection" width="55" :selectable="(row: Order) => row.status === 0 || row.status === 1" />
175 175 <el-table-column label="来源店铺" width="120" show-overflow-tooltip>
176 176 <template #default="{ row }">
177 177 <span style="font-size: 12px;">{{ row.shopName || '—' }}</span>
... ... @@ -185,7 +185,16 @@
185 185 <el-table-column prop="orderId" label="订单编号" min-width="300" show-overflow-tooltip>
186 186 <template #default="{ row }">
187 187 <div v-if="row.mergedOrderIds && row.mergedOrderIds.length > 1" style="line-height: 1.5;">
188   - <el-tag type="info" size="small" style="margin-bottom: 4px;">合并{{ row.mergedOrderIds.length }}单</el-tag>
  188 + <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
  189 + <el-tag type="info" size="small">合并{{ row.mergedOrderIds.length }}单</el-tag>
  190 + <el-button
  191 + v-if="row.status === 0"
  192 + type="warning"
  193 + size="small"
  194 + link
  195 + @click="handleSplitMerged(row)"
  196 + >拆分</el-button>
  197 + </div>
189 198 <div v-for="(num, i) in (row.mergedOrderNumbers || [row.orderId])" :key="i" style="font-size: 12px; word-break: break-all; display: flex; align-items: center; gap: 4px; margin-bottom: 2px;">
190 199 <span>{{ num }}</span>
191 200 <el-tag
... ... @@ -196,7 +205,13 @@
196 205 >{{ getStatusText(row.mergedOrderStatuses[i]) }}</el-tag>
197 206 </div>
198 207 </div>
199   - <span v-else style="white-space: nowrap;">{{ row.orderId }}</span>
  208 + <div v-else style="line-height: 1.5;">
  209 + <span style="white-space: nowrap;">{{ row.orderId }}</span>
  210 + <div v-if="row.noMerge && row.status === 0 && row.canUnsplit" style="margin-top: 3px; display: flex; align-items: center; gap: 6px;">
  211 + <el-tag type="warning" size="small">已拆分</el-tag>
  212 + <el-button type="primary" size="small" link @click="handleUnsplit(row)">恢复合并</el-button>
  213 + </div>
  214 + </div>
200 215 </template>
201 216 </el-table-column>
202 217 <el-table-column prop="status" label="状态" width="170" sortable="custom">
... ... @@ -345,13 +360,13 @@
345 360 </template>
346 361 </el-table-column>
347 362 <!-- 备注(合并买家留言和卖家备注) -->
348   - <el-table-column label="备注" min-width="140" show-overflow-tooltip>
  363 + <el-table-column label="备注" min-width="160">
349 364 <template #default="{ row }">
350 365 <div style="font-size: 11px; line-height: 1.5;">
351   - <div v-if="row.buyerWords" style="color: #409eff; margin-bottom: 3px;">
  366 + <div v-if="row.buyerWords" style="color: #409eff; margin-bottom: 3px; white-space: pre-line;" :title="row.buyerWords">
352 367 <span style="color: #909399; font-size: 10px;">买家:</span>{{ row.buyerWords }}
353 368 </div>
354   - <div v-if="row.sellerWords" style="color: #67c23a;">
  369 + <div v-if="row.sellerWords" style="color: #67c23a; white-space: pre-line;" :title="row.sellerWords">
355 370 <span style="color: #909399; font-size: 10px;">卖家:</span>{{ row.sellerWords }}
356 371 </div>
357 372 <div v-if="!row.buyerWords && !row.sellerWords" style="color: #909399;">
... ... @@ -449,7 +464,7 @@
449 464 import { ref, onMounted } from 'vue'
450 465 import { useRouter } from 'vue-router'
451 466 import { ElMessage, ElMessageBox } from 'element-plus'
452   -import { getOrders, syncOrders, createWaybill, initDatabase, printWaybill, shipToDouyin, manualShip, createSalesOrder, clearAndResync, getShops, type Order, type Shop } from '@/api/order'
  467 +import { getOrders, syncOrders, createWaybill, initDatabase, printWaybill, shipToDouyin, manualShip, createSalesOrder, clearAndResync, getShops, splitMergedOrders, unsplitOrders, type Order, type Shop } from '@/api/order'
453 468 import { getTokenByCredentials, getAuthorizeUrl } from '@/api/auth'
454 469 import { getBackendBaseUrl } from '@/utils/config'
455 470  
... ... @@ -923,6 +938,47 @@ const handleBatchCreateWaybill = async () =&gt; {
923 938 }
924 939 }
925 940  
  941 +// 拆分合并单:把一组合并订单重新拆开,分别发货
  942 +const handleSplitMerged = async (row: Order) => {
  943 + const ids = (row.mergedOrderIds && row.mergedOrderIds.length > 1) ? row.mergedOrderIds : [row.id]
  944 + if (ids.length < 2) {
  945 + ElMessage.info('该行不是合并订单,无需拆分')
  946 + return
  947 + }
  948 + try {
  949 + await ElMessageBox.confirm(
  950 + `确定要拆分这 ${ids.length} 笔合并订单吗?\n\n拆分后,这些订单将分别单独发货,不会再按"同买家+同地址"自动合并。如需恢复,可在"已拆分"标签旁点击"恢复合并"。`,
  951 + '拆分合并单',
  952 + { confirmButtonText: '确定拆分', cancelButtonText: '取消', type: 'warning' }
  953 + )
  954 + } catch {
  955 + return
  956 + }
  957 + try {
  958 + loading.value = true
  959 + const { data } = await splitMergedOrders(ids)
  960 + ElMessage.success(data?.message || `已拆分 ${ids.length} 笔订单`)
  961 + await fetchOrders()
  962 + fetchStats()
  963 + } catch (err: any) {
  964 + ElMessage.error(err?.response?.data?.message || err?.message || '拆分失败')
  965 + } finally {
  966 + loading.value = false
  967 + }
  968 +}
  969 +
  970 +// 恢复合并:取消"拆分"标记,下次列表会重新自动合并
  971 +const handleUnsplit = async (row: Order) => {
  972 + try {
  973 + const { data } = await unsplitOrders([row.id])
  974 + ElMessage.success(data?.message || '已恢复合并')
  975 + await fetchOrders()
  976 + fetchStats()
  977 + } catch (err: any) {
  978 + ElMessage.error(err?.response?.data?.message || err?.message || '恢复合并失败')
  979 + }
  980 +}
  981 +
926 982 // 创建运单(合并单跳转到合并编辑页;单笔订单走原有流程)
927 983 const handleCreateWaybill = async (order: Order) => {
928 984 if (order.status === 2 || order.status === 3 || order.status === 4) {
... ...
Antis.Erp.Plat/netcore/src/Application/NCC.API/appsettings.json
... ... @@ -183,7 +183,7 @@
183 183 "NCC_App": {
184 184 "CodeAreasName": "SubDev,Food,Extend,test",
185 185 //系统文件路径(末尾必须带斜杆)
186   - "SystemPath": "/Users/hexiaodong/Desktop/git/erp2025/Antis.Erp.Plat/netcore/uu-resources/",
  186 + "SystemPath": "/Users/mr.wang/代码库/file",
187 187 //微信公众号允许上传文件类型
188 188 "MPUploadFileType": "bmp,png,jpeg,jpg,gif,mp3,wma,wav,amr,mp4",
189 189 //微信允许上传文件类型
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdCrInput.cs
... ... @@ -32,6 +32,11 @@ namespace NCC.Extend.Entitys.Dto.WtXsckd
32 32 /// 经手人
33 33 /// </summary>
34 34 public string jsr { get; set; }
  35 +
  36 + /// <summary>
  37 + /// 发货人(抖音销售出库单必填;存用户 Id)
  38 + /// </summary>
  39 + public string fhr { get; set; }
35 40  
36 41 /// <summary>
37 42 /// 原价金额(订单原价,与收银台一致)
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdInfoOutput.cs
... ... @@ -37,6 +37,11 @@ namespace NCC.Extend.Entitys.Dto.WtXsckd
37 37 /// 经手人
38 38 /// </summary>
39 39 public string jsr { get; set; }
  40 +
  41 + /// <summary>
  42 + /// 发货人(详情展示 RealName;抖音销售出库单专用)
  43 + /// </summary>
  44 + public string fhr { get; set; }
40 45  
41 46 /// <summary>
42 47 /// 原价金额(订单原价)
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdListOutput.cs
... ... @@ -32,6 +32,11 @@ namespace NCC.Extend.Entitys.Dto.WtXsckd
32 32 /// 经手人
33 33 /// </summary>
34 34 public string jsr { get; set; }
  35 +
  36 + /// <summary>
  37 + /// 发货人(已转为 RealName 展示;抖音销售出库单专用)
  38 + /// </summary>
  39 + public string fhr { get; set; }
35 40  
36 41 /// <summary>
37 42 /// 原价金额(订单原价)
... ... @@ -133,6 +138,16 @@ namespace NCC.Extend.Entitys.Dto.WtXsckd
133 138 /// 收银批次号(同一次结账多单关联)
134 139 /// </summary>
135 140 public string sy_pch { get; set; }
  141 +
  142 + /// <summary>
  143 + /// 物流运单号
  144 + /// </summary>
  145 + public string yddh { get; set; }
  146 +
  147 + /// <summary>
  148 + /// 抖音订单号
  149 + /// </summary>
  150 + public string dyddh { get; set; }
136 151  
137 152 /// <summary>
138 153 /// 关联退货单单号(销售/预售出库单:逗号分隔的退货单 F_Id)
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/WtDyDpszEntity.cs
... ... @@ -38,9 +38,77 @@ namespace NCC.Extend.Entitys
38 38 public string Skzh { get; set; }
39 39  
40 40 /// <summary>
  41 + /// 出库仓库ID(抖音发货单专用,不再使用全局默认仓库)
  42 + /// </summary>
  43 + [SugarColumn(ColumnName = "F_Ck", IsNullable = true)]
  44 + public string Ck { get; set; }
  45 +
  46 + /// <summary>
41 47 /// 备注
42 48 /// </summary>
43 49 [SugarColumn(ColumnName = "F_Bz", Length = 500, IsNullable = true)]
44 50 public string Bz { get; set; }
  51 +
  52 + /// <summary>
  53 + /// 抖音开放平台 AppKey
  54 + /// </summary>
  55 + [SugarColumn(ColumnName = "F_AppKey", Length = 100, IsNullable = true)]
  56 + public string AppKey { get; set; }
  57 +
  58 + /// <summary>
  59 + /// 抖音开放平台 AppSecret
  60 + /// </summary>
  61 + [SugarColumn(ColumnName = "F_AppSecret", Length = 200, IsNullable = true)]
  62 + public string AppSecret { get; set; }
  63 +
  64 + /// <summary>
  65 + /// 授权回调地址
  66 + /// </summary>
  67 + [SugarColumn(ColumnName = "F_CallbackUrl", Length = 300, IsNullable = true)]
  68 + public string CallbackUrl { get; set; }
  69 +
  70 + /// <summary>
  71 + /// API 基础地址,默认 https://openapi-fxg.jinritemai.com
  72 + /// </summary>
  73 + [SugarColumn(ColumnName = "F_ApiBaseUrl", Length = 200, IsNullable = true)]
  74 + public string ApiBaseUrl { get; set; }
  75 +
  76 + /// <summary>
  77 + /// 同步订单查询天数(默认 30 天)
  78 + /// </summary>
  79 + [SugarColumn(ColumnName = "F_SyncDays", IsNullable = true)]
  80 + public int? SyncDays { get; set; }
  81 +
  82 + /// <summary>发货人姓名</summary>
  83 + [SugarColumn(ColumnName = "F_SenderName", Length = 100, IsNullable = true)]
  84 + public string SenderName { get; set; }
  85 +
  86 + /// <summary>发货人电话</summary>
  87 + [SugarColumn(ColumnName = "F_SenderPhone", Length = 50, IsNullable = true)]
  88 + public string SenderPhone { get; set; }
  89 +
  90 + /// <summary>发货人详细地址</summary>
  91 + [SugarColumn(ColumnName = "F_SenderAddress", Length = 300, IsNullable = true)]
  92 + public string SenderAddress { get; set; }
  93 +
  94 + /// <summary>发货人省份</summary>
  95 + [SugarColumn(ColumnName = "F_SenderProvince", Length = 50, IsNullable = true)]
  96 + public string SenderProvince { get; set; }
  97 +
  98 + /// <summary>发货人城市</summary>
  99 + [SugarColumn(ColumnName = "F_SenderCity", Length = 50, IsNullable = true)]
  100 + public string SenderCity { get; set; }
  101 +
  102 + /// <summary>发货人区县</summary>
  103 + [SugarColumn(ColumnName = "F_SenderDistrict", Length = 50, IsNullable = true)]
  104 + public string SenderDistrict { get; set; }
  105 +
  106 + /// <summary>发货人街道</summary>
  107 + [SugarColumn(ColumnName = "F_SenderStreet", Length = 100, IsNullable = true)]
  108 + public string SenderStreet { get; set; }
  109 +
  110 + /// <summary>启用状态:1=启用,0=停用(停用后不再同步订单)</summary>
  111 + [SugarColumn(ColumnName = "F_Enabled", IsNullable = true)]
  112 + public int? Enabled { get; set; }
45 113 }
46 114 }
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/WtXsckdEntity.cs
... ... @@ -40,6 +40,12 @@ namespace NCC.Extend.Entitys
40 40 /// </summary>
41 41 [SugarColumn(ColumnName = "jsr")]
42 42 public string Jsr { get; set; }
  43 +
  44 + /// <summary>
  45 + /// 发货人(抖音发货单提交销售出库时必填;独立于经手人。存 ERP 用户 Id)
  46 + /// </summary>
  47 + [SugarColumn(ColumnName = "fhr", IsNullable = true)]
  48 + public string Fhr { get; set; }
43 49  
44 50 /// <summary>
45 51 /// 原价金额(订单原价,与收银台一致)
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/BillSummary/WtBillSummaryService.cs
... ... @@ -218,15 +218,26 @@ namespace NCC.Extend.BillSummary
218 218 jsrDisp = jList[0] ?? "";
219 219 }
220 220  
  221 + // 与 GetInfo一致:gys 多为 wt_wldw 主键;历史可能为 wt_gys或已存展示名
221 222 string gysDisp = "";
222 223 if (!string.IsNullOrWhiteSpace(xsckd.Gys))
223 224 {
224   - var gList = await _db.Queryable<WtGysEntity>()
225   - .Where(g => g.Id == xsckd.Gys)
226   - .Select(g => g.Gysmc)
  225 + var rawGys = xsckd.Gys.Trim();
  226 + var wldwMcList = await _db.Queryable<WtWldwEntity>()
  227 + .Where(w => w.Id == rawGys)
  228 + .Select(w => w.Dwmc)
227 229 .ToListAsync();
228   - if (gList.Count > 0)
229   - gysDisp = gList[0] ?? "";
  230 + if (wldwMcList.Count > 0 && !string.IsNullOrWhiteSpace(wldwMcList[0]))
  231 + gysDisp = wldwMcList[0]!.Trim();
  232 + else
  233 + {
  234 + var gList = await _db.Queryable<WtGysEntity>()
  235 + .Where(g => g.Id == rawGys)
  236 + .Select(g => g.Gysmc)
  237 + .ToListAsync();
  238 + var gysmc = gList.Count > 0 ? gList[0] : null;
  239 + gysDisp = string.IsNullOrWhiteSpace(gysmc) ? rawGys : gysmc.Trim();
  240 + }
230 241 }
231 242  
232 243 var (cjckDisp, rkckDisp) = await ResolveMainWarehouseDisplayAsync(xsckd.Cjck, xsckd.Rkck);
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtDyDpszService.cs
... ... @@ -12,7 +12,7 @@ using Yitter.IdGenerator;
12 12 namespace NCC.Extend.WtDyDpsz
13 13 {
14 14 /// <summary>
15   - /// 抖音店铺设置服务(每个店铺对应的往来单位、收款账户
  15 + /// 抖音店铺设置服务(每个店铺对应的抖音 API 配置、往来单位、收款账户、发货人等
16 16 /// </summary>
17 17 [ApiDescriptionSettings(Tag = "Extend", Name = "WtDyDpsz", Order = 200)]
18 18 [Route("api/Extend/[controller]")]
... ... @@ -28,68 +28,149 @@ namespace NCC.Extend.WtDyDpsz
28 28 }
29 29  
30 30 /// <summary>
31   - /// 获取所有店铺设置
  31 + /// 确保 wt_dy_dpsz 表结构与实体同步(首次调用会检查并补齐新增列)。
32 32 /// </summary>
33   - [HttpGet("")]
34   - [AllowAnonymous]
35   - public async Task<dynamic> GetList()
  33 + private static bool _dpszColsEnsured;
  34 + private static readonly object _dpszColsLock = new object();
  35 + private void EnsureWtDyDpszColumns()
36 36 {
37   - var list = await _db.Queryable<WtDyDpszEntity>().ToListAsync();
38   - return list.Select(e => new
  37 + if (_dpszColsEnsured) return;
  38 + lock (_dpszColsLock)
  39 + {
  40 + if (_dpszColsEnsured) return;
  41 + try
  42 + {
  43 + var cols = _db.Ado.SqlQuery<string>(
  44 + "SELECT LOWER(COLUMN_NAME) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wt_dy_dpsz'");
  45 + var set = new HashSet<string>(cols ?? new List<string>());
  46 + if (set.Count > 0)
  47 + {
  48 + // 旧环境兼容:按需补齐列
  49 + if (!set.Contains("f_ck"))
  50 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_Ck` VARCHAR(64) NULL COMMENT '出库仓库ID(抖音发货单专用)'");
  51 + if (!set.Contains("f_appkey"))
  52 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_AppKey` VARCHAR(100) NULL COMMENT '抖音 AppKey'");
  53 + if (!set.Contains("f_appsecret"))
  54 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_AppSecret` VARCHAR(200) NULL COMMENT '抖音 AppSecret'");
  55 + if (!set.Contains("f_callbackurl"))
  56 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_CallbackUrl` VARCHAR(300) NULL COMMENT '授权回调地址'");
  57 + if (!set.Contains("f_apibaseurl"))
  58 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_ApiBaseUrl` VARCHAR(200) NULL COMMENT '抖音 API 基础地址'");
  59 + if (!set.Contains("f_syncdays"))
  60 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SyncDays` INT NULL COMMENT '同步订单查询天数'");
  61 + if (!set.Contains("f_sendername"))
  62 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderName` VARCHAR(100) NULL COMMENT '发货人姓名'");
  63 + if (!set.Contains("f_senderphone"))
  64 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderPhone` VARCHAR(50) NULL COMMENT '发货人电话'");
  65 + if (!set.Contains("f_senderaddress"))
  66 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderAddress` VARCHAR(300) NULL COMMENT '发货人地址'");
  67 + if (!set.Contains("f_senderprovince"))
  68 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderProvince` VARCHAR(50) NULL COMMENT '发货人省份'");
  69 + if (!set.Contains("f_sendercity"))
  70 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderCity` VARCHAR(50) NULL COMMENT '发货人城市'");
  71 + if (!set.Contains("f_senderdistrict"))
  72 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderDistrict` VARCHAR(50) NULL COMMENT '发货人区县'");
  73 + if (!set.Contains("f_senderstreet"))
  74 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_SenderStreet` VARCHAR(100) NULL COMMENT '发货人街道'");
  75 + if (!set.Contains("f_enabled"))
  76 + _db.Ado.ExecuteCommand("ALTER TABLE `wt_dy_dpsz` ADD COLUMN `F_Enabled` INT NULL DEFAULT 1 COMMENT '启用状态'");
  77 + }
  78 + }
  79 + catch
  80 + {
  81 + // 忽略:表不存在或无权限;由 CodeFirst/迁移流程处理
  82 + }
  83 + _dpszColsEnsured = true;
  84 + }
  85 + }
  86 +
  87 + /// <summary>
  88 + /// 转换为对外 DTO(前端/列表用,脱敏 AppSecret)。
  89 + /// </summary>
  90 + private static object ToOutputDto(WtDyDpszEntity e, bool includeSecret)
  91 + {
  92 + return new
39 93 {
40 94 id = e.Id,
41 95 shopId = e.ShopId,
42 96 shopName = e.ShopName ?? "",
43 97 kh = e.Kh ?? "",
44 98 skzh = e.Skzh ?? "",
45   - bz = e.Bz ?? ""
46   - });
  99 + ck = e.Ck ?? "",
  100 + bz = e.Bz ?? "",
  101 + appKey = e.AppKey ?? "",
  102 + // 列表页默认不回显 AppSecret,编辑时才返回
  103 + appSecret = includeSecret ? (e.AppSecret ?? "") : "",
  104 + callbackUrl = e.CallbackUrl ?? "",
  105 + apiBaseUrl = e.ApiBaseUrl ?? "",
  106 + syncDays = e.SyncDays ?? 30,
  107 + senderName = e.SenderName ?? "",
  108 + senderPhone = e.SenderPhone ?? "",
  109 + senderAddress = e.SenderAddress ?? "",
  110 + senderProvince = e.SenderProvince ?? "",
  111 + senderCity = e.SenderCity ?? "",
  112 + senderDistrict = e.SenderDistrict ?? "",
  113 + senderStreet = e.SenderStreet ?? "",
  114 + enabled = e.Enabled ?? 1
  115 + };
47 116 }
48 117  
49 118 /// <summary>
50   - /// 根据店铺ID获取设置
  119 + /// 获取所有店铺设置(列表用,AppSecret 脱敏)
  120 + /// </summary>
  121 + [HttpGet("")]
  122 + [AllowAnonymous]
  123 + public async Task<dynamic> GetList()
  124 + {
  125 + EnsureWtDyDpszColumns();
  126 + var list = await _db.Queryable<WtDyDpszEntity>().ToListAsync();
  127 + return list.Select(e => ToOutputDto(e, includeSecret: false));
  128 + }
  129 +
  130 + /// <summary>
  131 + /// 内部接口:抖音后端启动/刷新时拉取全部启用店铺的完整配置(含 AppSecret)
  132 + /// </summary>
  133 + [HttpGet("internal/all")]
  134 + [AllowAnonymous]
  135 + public async Task<dynamic> GetInternalAll()
  136 + {
  137 + EnsureWtDyDpszColumns();
  138 + var list = await _db.Queryable<WtDyDpszEntity>()
  139 + .Where(e => e.Enabled == null || e.Enabled == 1)
  140 + .ToListAsync();
  141 + return list.Select(e => ToOutputDto(e, includeSecret: true));
  142 + }
  143 +
  144 + /// <summary>
  145 + /// 根据店铺ID获取设置(供抖音发货单按店铺读取往来单位/收款账户/仓库)
51 146 /// </summary>
52 147 [HttpGet("by-shop/{shopId}")]
53 148 [AllowAnonymous]
54 149 public async Task<dynamic> GetByShopId(long shopId)
55 150 {
  151 + EnsureWtDyDpszColumns();
56 152 var entity = await _db.Queryable<WtDyDpszEntity>()
57 153 .Where(e => e.ShopId == shopId)
58 154 .FirstAsync();
59 155 if (entity == null)
60 156 return null;
61   - return new
62   - {
63   - id = entity.Id,
64   - shopId = entity.ShopId,
65   - shopName = entity.ShopName ?? "",
66   - kh = entity.Kh ?? "",
67   - skzh = entity.Skzh ?? "",
68   - bz = entity.Bz ?? ""
69   - };
  157 + return ToOutputDto(entity, includeSecret: false);
70 158 }
71 159  
72 160 /// <summary>
73   - /// 获取单条
  161 + /// 获取单条(编辑详情,返回 AppSecret)
74 162 /// </summary>
75 163 [HttpGet("{id}")]
76 164 [AllowAnonymous]
77 165 public async Task<dynamic> GetInfo(string id)
78 166 {
  167 + EnsureWtDyDpszColumns();
79 168 var entity = await _db.Queryable<WtDyDpszEntity>()
80 169 .Where(e => e.Id == id)
81 170 .FirstAsync();
82 171 if (entity == null)
83 172 return null;
84   - return new
85   - {
86   - id = entity.Id,
87   - shopId = entity.ShopId,
88   - shopName = entity.ShopName ?? "",
89   - kh = entity.Kh ?? "",
90   - skzh = entity.Skzh ?? "",
91   - bz = entity.Bz ?? ""
92   - };
  173 + return ToOutputDto(entity, includeSecret: true);
93 174 }
94 175  
95 176 /// <summary>
... ... @@ -99,6 +180,7 @@ namespace NCC.Extend.WtDyDpsz
99 180 [AllowAnonymous]
100 181 public async Task<dynamic> Create([FromBody] WtDyDpszInput input)
101 182 {
  183 + EnsureWtDyDpszColumns();
102 184 var exists = await _db.Queryable<WtDyDpszEntity>()
103 185 .Where(e => e.ShopId == input.ShopId)
104 186 .FirstAsync();
... ... @@ -108,12 +190,8 @@ namespace NCC.Extend.WtDyDpsz
108 190 var entity = new WtDyDpszEntity
109 191 {
110 192 Id = YitIdHelper.NextId().ToString(),
111   - ShopId = input.ShopId,
112   - ShopName = input.ShopName ?? "",
113   - Kh = input.Kh ?? "",
114   - Skzh = input.Skzh ?? "",
115   - Bz = input.Bz ?? ""
116 193 };
  194 + ApplyInputToEntity(input, entity, isCreate: true);
117 195 await _db.Insertable(entity).ExecuteCommandAsync();
118 196 return new { msg = "保存成功", id = entity.Id };
119 197 }
... ... @@ -125,17 +203,14 @@ namespace NCC.Extend.WtDyDpsz
125 203 [AllowAnonymous]
126 204 public async Task<dynamic> Update(string id, [FromBody] WtDyDpszInput input)
127 205 {
  206 + EnsureWtDyDpszColumns();
128 207 var entity = await _db.Queryable<WtDyDpszEntity>()
129 208 .Where(e => e.Id == id)
130 209 .FirstAsync();
131 210 if (entity == null)
132 211 return null;
133 212  
134   - entity.ShopId = input.ShopId;
135   - entity.ShopName = input.ShopName ?? "";
136   - entity.Kh = input.Kh ?? "";
137   - entity.Skzh = input.Skzh ?? "";
138   - entity.Bz = input.Bz ?? "";
  213 + ApplyInputToEntity(input, entity, isCreate: false);
139 214 await _db.Updateable(entity).ExecuteCommandAsync();
140 215 return new { msg = "保存成功" };
141 216 }
... ... @@ -150,6 +225,33 @@ namespace NCC.Extend.WtDyDpsz
150 225 await _db.Deleteable<WtDyDpszEntity>().Where(e => e.Id == id).ExecuteCommandAsync();
151 226 return new { msg = "删除成功" };
152 227 }
  228 +
  229 + private static void ApplyInputToEntity(WtDyDpszInput input, WtDyDpszEntity entity, bool isCreate)
  230 + {
  231 + entity.ShopId = input.ShopId;
  232 + entity.ShopName = input.ShopName ?? "";
  233 + entity.Kh = input.Kh ?? "";
  234 + entity.Skzh = input.Skzh ?? "";
  235 + entity.Ck = input.Ck ?? "";
  236 + entity.Bz = input.Bz ?? "";
  237 + entity.AppKey = input.AppKey ?? "";
  238 + // 编辑时如未传 AppSecret,保留数据库中的原值,避免 UI 未回显时误清空
  239 + if (isCreate || !string.IsNullOrEmpty(input.AppSecret))
  240 + entity.AppSecret = input.AppSecret ?? "";
  241 + entity.CallbackUrl = input.CallbackUrl ?? "";
  242 + entity.ApiBaseUrl = string.IsNullOrWhiteSpace(input.ApiBaseUrl)
  243 + ? "https://openapi-fxg.jinritemai.com"
  244 + : input.ApiBaseUrl;
  245 + entity.SyncDays = input.SyncDays > 0 ? input.SyncDays : 30;
  246 + entity.SenderName = input.SenderName ?? "";
  247 + entity.SenderPhone = input.SenderPhone ?? "";
  248 + entity.SenderAddress = input.SenderAddress ?? "";
  249 + entity.SenderProvince = input.SenderProvince ?? "";
  250 + entity.SenderCity = input.SenderCity ?? "";
  251 + entity.SenderDistrict = input.SenderDistrict ?? "";
  252 + entity.SenderStreet = input.SenderStreet ?? "";
  253 + entity.Enabled = input.Enabled.HasValue ? input.Enabled.Value : 1;
  254 + }
153 255 }
154 256  
155 257 public class WtDyDpszInput
... ... @@ -158,6 +260,30 @@ namespace NCC.Extend.WtDyDpsz
158 260 public string ShopName { get; set; }
159 261 public string Kh { get; set; }
160 262 public string Skzh { get; set; }
  263 + /// <summary>出库仓库ID(抖音发货单专用)</summary>
  264 + public string Ck { get; set; }
161 265 public string Bz { get; set; }
  266 +
  267 + /// <summary>抖音 AppKey</summary>
  268 + public string AppKey { get; set; }
  269 + /// <summary>抖音 AppSecret(编辑时留空表示不修改)</summary>
  270 + public string AppSecret { get; set; }
  271 + /// <summary>授权回调地址</summary>
  272 + public string CallbackUrl { get; set; }
  273 + /// <summary>API 基础地址</summary>
  274 + public string ApiBaseUrl { get; set; }
  275 + /// <summary>同步订单查询天数</summary>
  276 + public int SyncDays { get; set; }
  277 +
  278 + public string SenderName { get; set; }
  279 + public string SenderPhone { get; set; }
  280 + public string SenderAddress { get; set; }
  281 + public string SenderProvince { get; set; }
  282 + public string SenderCity { get; set; }
  283 + public string SenderDistrict { get; set; }
  284 + public string SenderStreet { get; set; }
  285 +
  286 + /// <summary>启用状态:1=启用,0=停用</summary>
  287 + public int? Enabled { get; set; }
162 288 }
163 289 }
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtSpService.cs
... ... @@ -756,6 +756,30 @@ CREATE TABLE `wt_sp_dysku` (
756 756 }
757 757  
758 758 /// <summary>
  759 + /// 商品编码唯一性检查:返回该编码是否已被其它商品占用
  760 + /// </summary>
  761 + /// <param name="spbm">待校验的商品编码(前后空白会自动 trim;空值视为"未填",返回 exists=false)</param>
  762 + /// <param name="id">编辑场景下传入当前商品主键,避免把自己当作冲突</param>
  763 + /// <returns><c>exists</c> 为 true 时表示已被其他商品占用,<c>id</c>/<c>spmc</c> 给出占用方,用于前端提示</returns>
  764 + [HttpGet("Actions/CheckSpbm")]
  765 + public async Task<dynamic> CheckSpbm([FromQuery] string spbm, [FromQuery] string id)
  766 + {
  767 + var code = spbm?.Trim();
  768 + if (string.IsNullOrEmpty(code))
  769 + return new { exists = false };
  770 +
  771 + var selfId = id?.Trim();
  772 + var dup = await _db.Queryable<WtSpEntity>()
  773 + .Where(p => p.Spbm == code)
  774 + .WhereIF(!string.IsNullOrEmpty(selfId), p => p.Id != selfId)
  775 + .Select(p => new { p.Id, p.Spmc })
  776 + .FirstAsync();
  777 +
  778 + if (dup == null) return new { exists = false };
  779 + return new { exists = true, id = dup.Id, spmc = dup.Spmc };
  780 + }
  781 +
  782 + /// <summary>
759 783 /// 一次性修复 wt_sp_cost 历史数据:ck 存仓库名称的改为仓库 ID;F_Id 为「商品_仓库」等非雪花主键的改为 YitId。
760 784 /// </summary>
761 785 private void MigrateSpCostCkToWarehouseId()
... ... @@ -1101,10 +1125,23 @@ CREATE TABLE `wt_sp_dysku` (
1101 1125 EnsureWtSpDyskuTable();
1102 1126 var mergedSkus = MergeDyspidInputs(input.dyspid, input.dyspidList);
1103 1127  
  1128 + // 商品编码唯一性校验:非空时 wt_sp.F_Spbm 不允许重复
  1129 + var newSpbm = input.spbm?.Trim();
  1130 + if (!string.IsNullOrEmpty(newSpbm))
  1131 + {
  1132 + var dup = await _db.Queryable<WtSpEntity>()
  1133 + .Where(p => p.Spbm == newSpbm)
  1134 + .Select(p => new { p.Id, p.Spmc })
  1135 + .FirstAsync();
  1136 + if (dup != null)
  1137 + throw NCCException.Oh($"商品编码「{newSpbm}」已存在(商品:{dup.Spmc}),请更换编码");
  1138 + }
  1139 +
1104 1140 var entity = input.Adapt<WtSpEntity>();
1105 1141 entity.Id = YitIdHelper.NextId().ToString();
1106 1142 entity.Pl = NormalizePlCsv(entity.Pl);
1107 1143 entity.Kc = 0;
  1144 + if (!string.IsNullOrEmpty(newSpbm)) entity.Spbm = newSpbm;
1108 1145 entity.Dyspid = mergedSkus.Count > 0 ? mergedSkus[0] : null;
1109 1146  
1110 1147 _db.BeginTran();
... ... @@ -1334,9 +1371,22 @@ CREATE TABLE `wt_sp_dysku` (
1334 1371 // 添加调试信息
1335 1372 Console.WriteLine($"更新商品 - ID: {id}");
1336 1373  
  1374 + // 商品编码唯一性校验:非空时排除自身后不允许重复
  1375 + var newSpbm = input.spbm?.Trim();
  1376 + if (!string.IsNullOrEmpty(newSpbm))
  1377 + {
  1378 + var dup = await _db.Queryable<WtSpEntity>()
  1379 + .Where(p => p.Spbm == newSpbm && p.Id != id)
  1380 + .Select(p => new { p.Id, p.Spmc })
  1381 + .FirstAsync();
  1382 + if (dup != null)
  1383 + throw NCCException.Oh($"商品编码「{newSpbm}」已被其他商品使用(商品:{dup.Spmc}),请更换编码");
  1384 + }
  1385 +
1337 1386 var entity = input.Adapt<WtSpEntity>();
1338 1387 entity.Pl = NormalizePlCsv(entity.Pl);
1339 1388 entity.Id = id;
  1389 + if (!string.IsNullOrEmpty(newSpbm)) entity.Spbm = newSpbm;
1340 1390 entity.Dyspid = mergedSkus.Count > 0 ? mergedSkus[0] : null;
1341 1391  
1342 1392 _db.BeginTran();
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtTjdService.cs
... ... @@ -299,7 +299,7 @@ namespace NCC.Extend.WtTjd
299 299 }
300 300  
301 301 /// <summary>
302   - /// 反审调价单:将商品档案零售价(<c>wt_sp.F_Lsj</c>)恢复为明细中的调前单价,状态回退为草稿
  302 + /// 反审调价单:将商品成本(<c>wt_sp_cost.cbj</c>,按本单仓库 + 商品定位)恢复为明细中的调前单价,状态回退为草稿
303 303 /// </summary>
304 304 /// <param name="id">单据编号</param>
305 305 /// <returns></returns>
... ... @@ -322,9 +322,13 @@ namespace NCC.Extend.WtTjd
322 322  
323 323 foreach (var mx in mxList)
324 324 {
325   - await _db.Updateable<WtSpEntity>()
326   - .SetColumns(it => new WtSpEntity { Lsj = mx.Tqdj })
327   - .Where(it => it.Id == mx.Spbh)
  325 + var ckId = !string.IsNullOrEmpty(mx.Ck) ? mx.Ck : entity.Ck;
  326 + if (string.IsNullOrEmpty(ckId) || string.IsNullOrEmpty(mx.Spbh)) continue;
  327 +
  328 + var tqdj = mx.Tqdj;
  329 + await _db.Updateable<WtSpCostEntity>()
  330 + .SetColumns(it => new WtSpCostEntity { Cbj = tqdj })
  331 + .Where(it => it.Spbh == mx.Spbh && it.Ck == ckId)
328 332 .ExecuteCommandAsync();
329 333 }
330 334  
... ... @@ -344,10 +348,10 @@ namespace NCC.Extend.WtTjd
344 348 }
345 349  
346 350 /// <summary>
347   - /// 按仓库加载商品列表(含库存、档案零售价 <c>tqdj</c>、成本参考 <c>cbj</c>)
  351 + /// 按仓库加载商品列表(含库存、调前成本 <c>tqdj</c>)
348 352 /// </summary>
349 353 /// <param name="ck">仓库ID</param>
350   - /// <returns>商品列表;<c>tqdj</c> 为 <c>wt_sp.F_Lsj</c>(调价基准),与「加载全部商品」口径一致</returns>
  354 + /// <returns>商品列表;<c>tqdj</c> 为当前成本价 <c>wt_sp_cost.cbj</c>(调价基准为"成本"而非零售价)</returns>
351 355 [HttpGet("Actions/LoadProducts")]
352 356 public async Task<dynamic> LoadProducts([FromQuery] string ck)
353 357 {
... ... @@ -355,9 +359,10 @@ namespace NCC.Extend.WtTjd
355 359 throw NCCException.Bah("仓库ID不能为空");
356 360  
357 361 // 库存数量仅以 wt_sp_cost.sl 为准(流水账);无成本行或 sl=0 则不出现,不再用序列号条数兜底
  362 + // tqdj(调前单价)= wt_sp_cost.cbj(成本价),调价单调的是"成本",不是零售价
358 363 var products = await _db.Ado.SqlQueryAsync<dynamic>(
359 364 @"SELECT sc.spbh, sp.F_Spbm as spbm, sp.F_Spmc as spmc, sc.sl,
360   - IFNULL(sp.F_Lsj, 0) as tqdj, IFNULL(sc.cbj, 0) as cbj
  365 + IFNULL(sc.cbj, 0) as tqdj, IFNULL(sc.cbj, 0) as cbj
361 366 FROM wt_sp_cost sc
362 367 INNER JOIN wt_sp sp ON sc.spbh = sp.F_Id
363 368 WHERE sc.ck = @ck AND sc.sl > 0
... ... @@ -494,7 +499,7 @@ namespace NCC.Extend.WtTjd
494 499  
495 500 /// <summary>
496 501 /// 加载某仓库曾在序列号表出现过的商品(按 <c>spbh</c> 聚合一条);在库数量 <c>sl</c> 取 <c>wt_sp_cost.sl</c>(流水账),不再按序列号条数统计。
497   - /// <c>tqdj</c> 为档案零售价 <c>wt_sp.F_Lsj</c>;<c>cbj</c> 为该仓成本单价
  502 + /// <c>tqdj</c> 为该仓调前成本 <c>wt_sp_cost.cbj</c>(调价单调的是成本,不再取零售价)
498 503 /// </summary>
499 504 /// <remarks>
500 505 /// 禁止按 <c>sn.spmc</c> 分组,否则同一商品因序列号表名称空/不一致会拆成多行。
... ... @@ -507,10 +512,11 @@ namespace NCC.Extend.WtTjd
507 512 if (string.IsNullOrEmpty(ck))
508 513 throw NCCException.Bah("仓库ID不能为空");
509 514  
  515 + // tqdj(调前单价)= 该仓 wt_sp_cost.cbj(成本价)
510 516 var products = await _db.Ado.SqlQueryAsync<dynamic>(
511 517 @"SELECT sn.spbh, MAX(sp.F_Spbm) as spbm, MAX(sp.F_Spmc) as spmc,
512 518 IFNULL(MAX(sc.sl), 0) as sl,
513   - IFNULL(MAX(sp.F_Lsj), 0) as tqdj, IFNULL(MAX(sc.cbj), 0) as cbj
  519 + IFNULL(MAX(sc.cbj), 0) as tqdj, IFNULL(MAX(sc.cbj), 0) as cbj
514 520 FROM wt_serial_number sn
515 521 LEFT JOIN wt_sp sp ON sn.spbh = sp.F_Id
516 522 LEFT JOIN wt_sp_cost sc ON sn.spbh = sc.spbh AND sc.ck = @ck
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtTjdWorkflowHelper.cs
... ... @@ -11,6 +11,8 @@ namespace NCC.Extend.WtTjd
11 11 {
12 12 /// <summary>
13 13 /// 商品调价单审批流(待办中心 / ApproveGeneric 转发),非独立 HTTP 控制器。
  14 + /// 注意:调价单调的是"成本",审核通过后将明细 <c>Thdj</c>(调后单价)写入 <c>wt_sp_cost.cbj</c>,
  15 + /// 不再写商品档案零售价 <c>wt_sp.F_Lsj</c>。
14 16 /// </summary>
15 17 public class WtTjdWorkflowHelper : ITransient
16 18 {
... ... @@ -101,7 +103,7 @@ namespace NCC.Extend.WtTjd
101 103 foreach (var mx in mxList)
102 104 {
103 105 if (string.IsNullOrWhiteSpace(mx.Spbh) || mx.Thdj <= 0)
104   - return new { success = false, message = "明细中存在未填写商品或调后售价为 0,请检查后再提交" };
  106 + return new { success = false, message = "明细中存在未填写商品或调后成本为 0,请检查后再提交" };
105 107 }
106 108  
107 109 entity.Djzt = "待审核";
... ... @@ -177,7 +179,7 @@ namespace NCC.Extend.WtTjd
177 179 return new { success = true, message = "一级审核通过,等待二级审核" };
178 180 }
179 181  
180   - await ApplyFinalRetailPricesAsync(entity, mxList, userId, remark, "审核通过");
  182 + await ApplyFinalCostPricesAsync(entity, mxList, userId, remark, "审核通过");
181 183 _db.CommitTran();
182 184 return new { success = true, message = "审核通过" };
183 185 }
... ... @@ -200,7 +202,7 @@ namespace NCC.Extend.WtTjd
200 202 return new { success = false, message = "当前用户不在商品调价单二级审核人员范围内" };
201 203 }
202 204  
203   - await ApplyFinalRetailPricesAsync(entity, mxList, userId, remark, "二级通过");
  205 + await ApplyFinalCostPricesAsync(entity, mxList, userId, remark, "二级通过");
204 206 _db.CommitTran();
205 207 return new { success = true, message = "二级审核通过,审核完成" };
206 208 }
... ... @@ -215,7 +217,11 @@ namespace NCC.Extend.WtTjd
215 217 }
216 218 }
217 219  
218   - private async Task ApplyFinalRetailPricesAsync(WtTjdEntity entity, List<WtTjdMxEntity> mxList, string userId, string remark, string spbzTag)
  220 + /// <summary>
  221 + /// 终审通过:将明细 <c>Thdj</c>(调后单价)写入 <c>wt_sp_cost.cbj</c>(按本单仓库 + 商品定位),
  222 + /// 调价单调的是"成本",不再写商品档案零售价 <c>wt_sp.F_Lsj</c>
  223 + /// </summary>
  224 + private async Task ApplyFinalCostPricesAsync(WtTjdEntity entity, List<WtTjdMxEntity> mxList, string userId, string remark, string spbzTag)
219 225 {
220 226 entity.Djzt = "已审核";
221 227 entity.Shr = userId;
... ... @@ -225,9 +231,13 @@ namespace NCC.Extend.WtTjd
225 231  
226 232 foreach (var mx in mxList)
227 233 {
228   - await _db.Updateable<WtSpEntity>()
229   - .SetColumns(it => new WtSpEntity { Lsj = mx.Thdj })
230   - .Where(it => it.Id == mx.Spbh)
  234 + var ckId = !string.IsNullOrEmpty(mx.Ck) ? mx.Ck : entity.Ck;
  235 + if (string.IsNullOrEmpty(ckId) || string.IsNullOrEmpty(mx.Spbh)) continue;
  236 +
  237 + var thdj = mx.Thdj;
  238 + await _db.Updateable<WtSpCostEntity>()
  239 + .SetColumns(it => new WtSpCostEntity { Cbj = thdj })
  240 + .Where(it => it.Spbh == mx.Spbh && it.Ck == ckId)
231 241 .ExecuteCommandAsync();
232 242 }
233 243 }
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtXsckdService.cs
... ... @@ -64,6 +64,7 @@ namespace NCC.Extend.WtXsckd
64 64 private static bool _syPchColumnChecked;
65 65 private static bool _yddhColumnChecked;
66 66 private static bool _dyddhColumnChecked;
  67 + private static bool _fhrColumnChecked;
67 68 private static bool _spCostCkMigrated;
68 69 private static bool _salesReturnMxCostSnapshotMigrated;
69 70 private static int _salesReturnMxCostSnapshotRunCount;
... ... @@ -391,6 +392,32 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
391 392 }
392 393  
393 394 /// <summary>
  395 + /// 确保发货人列 fhr 存在(抖音发货单提交销售出库时必填;独立于经手人)
  396 + /// </summary>
  397 + private void EnsureFhrColumn()
  398 + {
  399 + if (_fhrColumnChecked) return;
  400 + lock (typeof(WtXsckdService))
  401 + {
  402 + if (_fhrColumnChecked) return;
  403 + try
  404 + {
  405 + if (!_db.DbMaintenance.IsAnyTable("wt_xsckd")) { _fhrColumnChecked = true; return; }
  406 + var columns = _db.DbMaintenance.GetColumnInfosByTableName("wt_xsckd");
  407 + var columnNames = columns.Select(c => c.DbColumnName.ToLower()).ToHashSet();
  408 + if (!columnNames.Contains("fhr"))
  409 + {
  410 + _db.Ado.ExecuteCommand(
  411 + "ALTER TABLE `wt_xsckd` ADD COLUMN `fhr` varchar(64) NULL COMMENT '发货人(用户Id)'");
  412 + Console.WriteLine("✅ 已添加字段: fhr (发货人)");
  413 + }
  414 + }
  415 + catch (Exception ex) { Console.WriteLine($"EnsureFhrColumn: {ex.Message}"); }
  416 + _fhrColumnChecked = true;
  417 + }
  418 + }
  419 +
  420 + /// <summary>
394 421 /// 确保付款明细列 fkmx 存在(会员余额/积分抵扣等 JSON)
395 422 /// </summary>
396 423 private void EnsureFkmxColumn()
... ... @@ -1721,6 +1748,7 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
1721 1748 EnsureSyPchColumn();
1722 1749 EnsureYddhColumn();
1723 1750 EnsureDyddhColumn();
  1751 + EnsureFhrColumn();
1724 1752 MigrateSpCostCkToWarehouseId();
1725 1753 var entity = await _db.Queryable<WtXsckdEntity>().FirstAsync(p => p.Id == id);
1726 1754 _ = entity ?? throw NCCException.Oh(ErrorCode.COM1005);
... ... @@ -1930,8 +1958,9 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
1930 1958 EnsureSyPchColumn();
1931 1959 EnsureYddhColumn();
1932 1960 EnsureDyddhColumn();
  1961 + EnsureFhrColumn();
1933 1962 EnsureBjsxColumn();
1934   - var sidx = input.sidx == null ? "id" : input.sidx;
  1963 + var sidx = string.IsNullOrWhiteSpace(input.sidx) ? "id" : input.sidx;
1935 1964 var ycddhFilter = string.IsNullOrWhiteSpace(input.ycddh) ? null : input.ycddh.Trim();
1936 1965 string linkedReturnExcludeDjlx = null;
1937 1966 var excludeConsignmentLinkedReturn = false;
... ... @@ -2000,6 +2029,8 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2000 2029 rkck = xsckd.Rkck,
2001 2030 // jsr=xsckd.Jsr,
2002 2031 jsr = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == xsckd.Jsr).Select(u => u.RealName),
  2032 + // 发货人:存用户 Id,展示 RealName
  2033 + fhr = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == xsckd.Fhr).Select(u => u.RealName),
2003 2034 ydje = xsckd.Ydje,
2004 2035 cbje = xsckd.Cbje,
2005 2036 bjsx = xsckd.Bjsx,
... ... @@ -2027,7 +2058,9 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2027 2058 ly = xsckd.Ly,
2028 2059 ycddh = xsckd.Ycddh,
2029 2060 ytwtfhd = xsckd.YtWtfhd,
2030   - sy_pch = xsckd.SyPch
  2061 + sy_pch = xsckd.SyPch,
  2062 + yddh = xsckd.Yddh,
  2063 + dyddh = xsckd.Dyddh
2031 2064 }).MergeTable()
2032 2065 // ycddh 条件放在 MergeTable 之后:Join+Select 子查询路径上部分 Where 在分页前未正确生效
2033 2066 .WhereIF(!string.IsNullOrEmpty(ycddhFilter), it =>
... ... @@ -2182,7 +2215,11 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2182 2215 private async Task EnrichListOutputGysWldwDisplayAsync(List<WtXsckdListOutput> items)
2183 2216 {
2184 2217 if (items == null || items.Count == 0) return;
2185   - var targets = items.Where(x => x.djlx == "采购入库单" || x.djlx == "采购退货单").ToList();
  2218 + var targets = items.Where(x =>
  2219 + {
  2220 + var lx = (x.djlx ?? string.Empty).Trim();
  2221 + return lx == "采购入库单" || lx == "采购退货单";
  2222 + }).ToList();
2186 2223 if (targets.Count == 0) return;
2187 2224 var billIds = targets.Select(x => x.id).Where(id => !string.IsNullOrEmpty(id)).Select(id => id.Trim()).Distinct().ToList();
2188 2225 if (billIds.Count == 0) return;
... ... @@ -2207,6 +2244,24 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2207 2244 .Where(x => !string.IsNullOrEmpty(x.Id))
2208 2245 .ToDictionary(x => x.Id.Trim(), x => x.Dwmc ?? "", StringComparer.Ordinal);
2209 2246  
  2247 + var unresolvedForLegacyGys = rawGysIds
  2248 + .Where(k => !wldwDwmcById.TryGetValue(k, out var mc) || string.IsNullOrWhiteSpace(mc))
  2249 + .Distinct()
  2250 + .ToList();
  2251 + Dictionary<string, string> gysMcById = new Dictionary<string, string>(StringComparer.Ordinal);
  2252 + if (unresolvedForLegacyGys.Count > 0)
  2253 + {
  2254 + var legacyRows = await _db.Queryable<WtGysEntity>()
  2255 + .Where(g => unresolvedForLegacyGys.Contains(g.Id))
  2256 + .Select(g => new { g.Id, g.Gysmc })
  2257 + .ToListAsync();
  2258 + foreach (var r in legacyRows)
  2259 + {
  2260 + if (string.IsNullOrEmpty(r.Id) || string.IsNullOrWhiteSpace(r.Gysmc)) continue;
  2261 + gysMcById[r.Id.Trim()] = r.Gysmc.Trim();
  2262 + }
  2263 + }
  2264 +
2210 2265 foreach (var row in targets)
2211 2266 {
2212 2267 var bid = row.id?.Trim();
... ... @@ -2215,6 +2270,10 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2215 2270 var key = rawGys.Trim();
2216 2271 if (wldwDwmcById.TryGetValue(key, out var dwmc) && !string.IsNullOrWhiteSpace(dwmc))
2217 2272 row.gys = dwmc.Trim();
  2273 + else if (gysMcById.TryGetValue(key, out var gysmc) && !string.IsNullOrWhiteSpace(gysmc))
  2274 + row.gys = gysmc;
  2275 + else
  2276 + row.gys = key;
2218 2277 }
2219 2278 }
2220 2279  
... ... @@ -2341,7 +2400,7 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2341 2400 jsrLookup = entity.Zdr;
2342 2401 }
2343 2402  
2344   - var ids = new[] { jsrLookup, entity.Zdr, entity.Shr, entity.Gzr }
  2403 + var ids = new[] { jsrLookup, entity.Zdr, entity.Shr, entity.Gzr, entity.Fhr }
2345 2404 .Where(s => !string.IsNullOrWhiteSpace(s))
2346 2405 .Select(s => s.Trim())
2347 2406 .Distinct()
... ... @@ -2389,6 +2448,8 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2389 2448 await TrySetNameAsync(entity.Zdr, v => output.zdr = v);
2390 2449 await TrySetNameAsync(entity.Shr, v => output.shr = v);
2391 2450 await TrySetNameAsync(entity.Gzr, v => output.gzr = v);
  2451 + // 发货人:抖音单传入的是用户 Id,这里转 RealName;不允许 hy 兜底,避免误把会员名作发货人
  2452 + await TrySetNameAsync(entity.Fhr, v => output.fhr = v, allowHyFallback: false);
2392 2453 }
2393 2454  
2394 2455 /// <summary>
... ... @@ -2536,6 +2597,7 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
2536 2597 EnsureSyPchColumn();
2537 2598 EnsureYddhColumn();
2538 2599 EnsureDyddhColumn();
  2600 + EnsureFhrColumn();
2539 2601 EnsureBjsxColumn();
2540 2602 EnsureBjhcbColumn();
2541 2603 // 匿名访问时,userInfo可能为null,需要安全处理
... ... @@ -3029,8 +3091,9 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
3029 3091 EnsureSyPchColumn();
3030 3092 EnsureYddhColumn();
3031 3093 EnsureDyddhColumn();
  3094 + EnsureFhrColumn();
3032 3095 EnsureBjsxColumn();
3033   - var sidx = input.sidx == null ? "id" : input.sidx;
  3096 + var sidx = string.IsNullOrWhiteSpace(input.sidx) ? "id" : input.sidx;
3034 3097 var ycddhFilterNp = string.IsNullOrWhiteSpace(input.ycddh) ? null : input.ycddh.Trim();
3035 3098 List<string> queryDjrq = input.djrq != null ? input.djrq.Split(',').ToObeject<List<string>>() : null;
3036 3099 DateTime? startDjrq = queryDjrq != null ? Ext.GetDateTime(queryDjrq.First()) : null;
... ... @@ -3079,6 +3142,7 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
3079 3142 cjck = xsckd.Cjck,
3080 3143 rkck = xsckd.Rkck,
3081 3144 jsr = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == xsckd.Jsr).Select(u => u.RealName),
  3145 + fhr = SqlFunc.Subqueryable<UserEntity>().Where(u => u.Id == xsckd.Fhr).Select(u => u.RealName),
3082 3146 ydje = xsckd.Ydje,
3083 3147 cbje = xsckd.Cbje,
3084 3148 bjsx = xsckd.Bjsx,
... ... @@ -3099,7 +3163,9 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
3099 3163 ly = xsckd.Ly,
3100 3164 ycddh = xsckd.Ycddh,
3101 3165 ytwtfhd = xsckd.YtWtfhd,
3102   - sy_pch = xsckd.SyPch
  3166 + sy_pch = xsckd.SyPch,
  3167 + yddh = xsckd.Yddh,
  3168 + dyddh = xsckd.Dyddh
3103 3169 }).MergeTable()
3104 3170 .WhereIF(!string.IsNullOrEmpty(ycddhFilterNp), it =>
3105 3171 it.ycddh == ycddhFilterNp
... ... @@ -3304,6 +3370,7 @@ WHERE d.djlx IN (&#39;销售退货单&#39;,&#39;预售退货单&#39;,&#39;委托代销退货单&#39;)
3304 3370 EnsureSyPchColumn();
3305 3371 EnsureYddhColumn();
3306 3372 EnsureDyddhColumn();
  3373 + EnsureFhrColumn();
3307 3374 EnsureBjsxColumn();
3308 3375 EnsureBjhcbColumn();
3309 3376 var oldHeader = await _db.Queryable<WtXsckdEntity>().FirstAsync(p => p.Id == id);
... ... @@ -5284,8 +5351,24 @@ LIMIT {offset}, {pageSize}&quot;;
5284 5351 return new { serialNumbers = new List<object>() };
5285 5352  
5286 5353 var code = productCode.Trim();
  5354 +
  5355 + // 标准化 productCode:允许前端传 F_Id / F_Spbm / F_Dyspid 任一种,统一到序列号表使用的 F_Id
  5356 + // 序列号表 spbh 字段存的是 wt_sp.F_Id(商品主键)
  5357 + var productCodeEncode = "";
  5358 + var normalizedSpbh = code;
  5359 + var spRow = await _db.Queryable<WtSpEntity>()
  5360 + .Where(p => p.Id == code || p.Spbm == code || p.Dyspid == code)
  5361 + .Select(p => new { p.Id, p.Spbm })
  5362 + .FirstAsync();
  5363 + if (spRow != null)
  5364 + {
  5365 + if (!string.IsNullOrEmpty(spRow.Id)) normalizedSpbh = spRow.Id;
  5366 + if (!string.IsNullOrEmpty(spRow.Spbm)) productCodeEncode = spRow.Spbm;
  5367 + }
  5368 +
  5369 + // 使用标准化后的 F_Id 查询序列号表;避免 StringComparison 重载在 SqlSugar 下翻译不稳定
5287 5370 var baseQuery = _db.Queryable<WtSerialNumberEntity>()
5288   - .Where(s => s.Spbh.Trim().Equals(code, StringComparison.OrdinalIgnoreCase));
  5371 + .Where(s => s.Spbh == normalizedSpbh);
5289 5372  
5290 5373 // 根据单据类型决定查询状态
5291 5374 // 退货单:查询已售出的序列号(Status = 1),因为要退货
... ... @@ -5307,22 +5390,14 @@ LIMIT {offset}, {pageSize}&quot;;
5307 5390 var queryWithWarehouse = baseQuery;
5308 5391 if (!string.IsNullOrEmpty(warehouse))
5309 5392 {
5310   - queryWithWarehouse = queryWithWarehouse.Where(s => s.InWarehouse.Equals(warehouse.Trim(), StringComparison.OrdinalIgnoreCase));
  5393 + var warehouseTrim = warehouse.Trim();
  5394 + queryWithWarehouse = queryWithWarehouse.Where(s => s.InWarehouse == warehouseTrim);
5311 5395 }
5312 5396 if (!string.IsNullOrEmpty(serialNumber))
5313 5397 {
5314 5398 queryWithWarehouse = queryWithWarehouse.Where(s => s.SerialNumber.Contains(serialNumber));
5315 5399 }
5316 5400  
5317   - // 从商品档案获取商品编码(wt_sp.F_Spbm),用于收银台序列号选择界面显示;支持按主键 F_Id 或按 F_Spbm 匹配
5318   - var productCodeEncode = "";
5319   - var spRow = await _db.Queryable<WtSpEntity>()
5320   - .Where(p => p.Id == code || p.Spbm == code)
5321   - .Select(p => new { p.Spbm })
5322   - .FirstAsync();
5323   - if (spRow != null && !string.IsNullOrEmpty(spRow.Spbm))
5324   - productCodeEncode = spRow.Spbm;
5325   -
5326 5401 // 先按商品+仓库过滤,如果没有结果,再退回到只按商品查询,避免因为仓库编码不一致导致查不到数据
5327 5402 var list1 = await queryWithWarehouse
5328 5403 .Select(s => new {
... ...