From d3d42d61809504d652ca2f4ec22b9c3a468c7996 Mon Sep 17 00:00:00 2001 From: “wangming” <“wangming@antissoft.com”> Date: Fri, 17 Apr 2026 18:32:46 +0800 Subject: [PATCH] ``` feat(utils): 支持退款模式的收付款显示格式化 --- Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js | 21 ++++++++++++++++----- Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue | 4 ++-- Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue | 8 ++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue | 14 ++++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue | 1 + Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/index.vue | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- Antis.Erp.Plat/antis-ncc-admin/src/views/wtHzd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtPdd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtPriceAdjust/Form.vue | 19 +++++++------------ Antis.Erp.Plat/antis-ncc-admin/src/views/wtPriceAdjust/detail-view.vue | 8 ++++---- Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/Form.vue | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/index.vue | 44 +++++++++++++++++--------------------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtTjdbd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/Form.vue | 29 ++++++----------------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/detail-view.vue | 22 ++++++++++++++++------ Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsckd/index.vue | 53 +++++++++++++++++++++++++++-------------------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/detail-view.vue | 7 ++++++- Antis.Erp.Plat/antis-ncc-admin/src/views/wtXsthd/index.vue | 39 +++++++++++++++++++++++---------------- Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue | 2 ++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue | 11 +++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue | 11 +++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue | 40 ++++++++++++++++++++++------------------ Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue | 12 ++++++++++++ Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue | 7 ++++++- Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue | 39 +++++++++++++++++++++++---------------- Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs | 44 ++++++++++++++++++++++++++++++++++++++++---- Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs | 12 ++++++++++++ Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs | 13 +++++++++++++ Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++--------- Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs | 25 +++++++++++++++++++++---- Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs | 533 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------- Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json | 20 ++------------------ Antis.Erp.Plat/douyin/frontend/src/api/order.ts | 19 +++++++++++++++++++ Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Antis.Erp.Plat/douyin/frontend/src/views/CreateWaybillView.vue | 516 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------- Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- Antis.Erp.Plat/netcore/src/Application/NCC.API/appsettings.json | 2 +- Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdCrInput.cs | 5 +++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdInfoOutput.cs | 5 +++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtXsckdListOutput.cs | 15 +++++++++++++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/WtDyDpszEntity.cs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/WtXsckdEntity.cs | 6 ++++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/BillSummary/WtBillSummaryService.cs | 21 ++++++++++++++++----- Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtDyDpszService.cs | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------- Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtSpService.cs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtTjdService.cs | 24 +++++++++++++++--------- Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtTjdWorkflowHelper.cs | 24 +++++++++++++++++------- Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtXsckdService.cs | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- 58 files changed, 2935 insertions(+), 772 deletions(-) create mode 100644 Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js b/Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js index 16efef2..108d78b 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js +++ b/Antis.Erp.Plat/antis-ncc-admin/src/utils/wtComboSkzhDisplay.js @@ -59,11 +59,22 @@ export function parseWtMxJsonArray(val) { * * @param {Object} row - { skzh, skmx } * @param {Array} skzhOptions - 与 dynamicText 一致的字典选项 + * @param {Object} [opts] - 可选配置 + * - mode: 'sale' | 'refund',默认 'sale';refund 模式下 + * skmx 段前缀显示「退款」,fkmx 段前缀显示「退回」, + * 并把 `组合支付` 文案兜底为「组合退款」 + * - skLabel / fkLabel: 显式指定段前缀,优先级高于 mode + * - fallbackText: 无法解析时兜底文字 * @returns {string} */ -export function formatWtSkzhDisplay(row, skzhOptions) { +export function formatWtSkzhDisplay(row, skzhOptions, opts) { if (!row || row.skzh === null || row.skzh === undefined || row.skzh === '') return '无' const options = skzhOptions || [] + const o = opts || {} + const mode = o.mode === 'refund' ? 'refund' : 'sale' + const skLabel = o.skLabel || (mode === 'refund' ? '退款' : '收款') + const fkLabel = o.fkLabel || (mode === 'refund' ? '退回' : '付款') + const fallback = o.fallbackText || (mode === 'refund' ? '组合退款' : '组合支付') if (row.skzh === '组合支付' && (row.skmx || row.fkmx)) { try { const { list: skList } = row.skmx ? parseWtMxJsonArray(row.skmx) : { list: [] } @@ -98,11 +109,11 @@ export function formatWtSkzhDisplay(row, skzhOptions) { } } const seg = [] - if (skParts.length) seg.push(`收款:${skParts.join(';')}`) - if (fkParts.length) seg.push(`付款:${fkParts.join(';')}`) - return seg.join('|') || '组合支付' + if (skParts.length) seg.push(`${skLabel}:${skParts.join(';')}`) + if (fkParts.length) seg.push(`${fkLabel}:${fkParts.join(';')}`) + return seg.join('|') || fallback } catch (e) { - return '组合支付' + return fallback } } const byDict = resolveSkzhDictionaryLabel(row.skzh, options) diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue index f71405f..791b216 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/basic/todoCenter/index.vue @@ -654,7 +654,7 @@ export default { } if (t === "商品调价单") { return { - msg: "确认审核该商品调价单?通过后调后售价将写入商品档案零售价。", + msg: "确认审核该商品调价单?通过后调后成本将写入该仓库商品成本(wt_sp_cost.cbj)。", title: "审核确认" }; } @@ -697,7 +697,7 @@ export default { } if (t === "商品调价单") { return { - msg: "确认二级审核该商品调价单?通过后调后售价将写入商品档案零售价。", + msg: "确认二级审核该商品调价单?通过后调后成本将写入该仓库商品成本(wt_sp_cost.cbj)。", title: "二级审核确认" }; } diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue index dac904d..eff4405 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBjdbd/Form.vue @@ -1639,6 +1639,18 @@ return label.includes(query.toLowerCase()); }, async handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'kucun', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } // 选中商品后可自动回填商品名称等信息 const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue index ed1604e..41254b6 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtBsd/Form.vue @@ -1422,6 +1422,18 @@ return label.includes(query.toLowerCase()); }, handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'kucun', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } // 选中商品后可自动回填商品名称等信息 const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue index bea0379..5f0fdba 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtByd/Form.vue @@ -1422,6 +1422,18 @@ return label.includes(query.toLowerCase()); }, handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'kucun', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } // 选中商品后可自动回填商品名称等信息 const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue index b5cff5a..7d7c425 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgrkd/Form.vue @@ -343,6 +343,14 @@ setFullName(item,row){ } }, handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + return + } const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { row.spmc = product.F_Spmc || ''; diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue index 400f2f8..60b2f4b 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/Form.vue @@ -648,6 +648,20 @@ this.updateTotalAmount(); }, async handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'sptm', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', '') + this.$set(row, 'thdj', '') + this.$set(row, 'je', '') + this.$set(row, 'description', '') + this.$set(row, 'dyddbh', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { row.spmc = product.F_Spmc || ''; diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue index cedc4e0..3d524d4 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCgthd/detail-view.vue @@ -116,17 +116,27 @@ {{ formatMoneyCol(scope.row.je) }} - + @@ -179,6 +189,26 @@ 暂无数据 + + +
共 {{ serialDialogList.length }} 条
+
+ {{ sn }} +
+
@@ -196,7 +226,12 @@ export default { loading: false, detail: null, ckckOptions: [], - skzhOptions: [] + skzhOptions: [], + /** 明细格内最多直接展示的序列号个数,超出则仅预览前若干 + 点击查看 */ + snInlineMax: 8, + snPreviewCount: 4, + serialDialogVisible: false, + serialDialogList: [] } }, computed: { @@ -215,6 +250,20 @@ export default { } }, methods: { + serialList(row) { + const raw = row && row.selectedSerialNumbers + if (!raw || !raw.length) return [] + return raw.map(s => (s == null ? '' : String(s)).trim()).filter(Boolean) + }, + serialPreview(row) { + const all = this.serialList(row) + if (all.length <= this.snInlineMax) return all + return all.slice(0, this.snPreviewCount) + }, + openSerialDialog(row) { + this.serialDialogList = this.serialList(row).slice() + this.serialDialogVisible = true + }, labelFromOptions(value, options) { if (value === null || value === undefined || value === '') return '无' const opts = options || [] @@ -261,6 +310,8 @@ export default { return sums }, handleClose() { + this.serialDialogVisible = false + this.serialDialogList = [] this.detail = null this.$emit('close') }, @@ -450,6 +501,29 @@ export default { .sn-tag { margin: 0 !important; } +.sn-more-btn { + padding: 0 4px !important; + margin-left: 2px; + vertical-align: middle; + font-size: 12px; +} +.sn-dialog-hint { + font-size: 12px; + color: #909399; + margin: -6px 0 10px; +} +.sn-dialog-tags { + max-height: min(420px, 60vh); + overflow-y: auto; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-content: flex-start; + padding: 2px 0; +} +.sn-dialog-tag { + margin: 0 !important; +} .detail-bz { white-space: pre-wrap; word-break: break-all; diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue index 556648c..8adee98 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtCzd/Form.vue @@ -414,6 +414,7 @@ this.$set(row, 'xlh', ''); this.$set(row, 'selectedSerialNumbers', []); this.$set(row, 'zmkc', 0); + this.$set(row, 'sl', undefined); this.$set(row, 'cbdj', ''); this.$set(row, 'cbje', ''); } diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue index a4736e5..448db39 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtDyDpsz/Form.vue @@ -1,25 +1,69 @@ \ No newline at end of file diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue index 13b9c79..4551418 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxfhd/Form.vue @@ -1480,7 +1480,9 @@ row.dj = undefined; row.je = undefined; row.dqxsj = undefined; + row.description = undefined; this.$set(row, 'selectedSerialNumbers', []); + this.$set(row, 'xlhList', []); this.$set(row, 'productQuery', ''); row.loadingStock = false; this.$set(row, 'spxlhLoaded', false); diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue index e4494db..cd79122 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxjsd/Form.vue @@ -1405,6 +1405,17 @@ return label.includes(query.toLowerCase()); }, handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'kucun', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } // 选中商品后可自动回填商品名称等信息 const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue index 1040e48..97f397b 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtXswtdxthd/Form.vue @@ -647,6 +647,18 @@ this.updateTotalAmount(); }, async handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'sptm', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } const product = this.spbhOptions.find(item => item.F_Id === row.spbh); console.log('选中商品', row.spbh, product); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue index b3723a3..4383e9a 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/Form.vue @@ -1434,6 +1434,17 @@ return label.includes(query.toLowerCase()); }, handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'kucun', undefined) + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } // 选中商品后可自动回填商品名称等信息 const product = this.spbhOptions.find(item => item.F_Id === row.spbh); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue index f048ee1..4294941 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsckd/index.vue @@ -116,38 +116,38 @@ - - - - + + + + - - - + + + - + - + - + - - - - + + + + - - + + - + - + @@ -743,4 +743,8 @@ .convert-button-wrapper .el-button { pointer-events: auto !important; } + + .wt-ysckd-table ::v-deep .el-table .cell { + white-space: nowrap; + } \ No newline at end of file diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue index e5d65e3..f5cb0c4 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/Form.vue @@ -603,6 +603,18 @@ this.updateTotalAmount(); }, async handleProductChange(row) { + if (!row.spbh) { + this.$set(row, 'spmc', '') + this.$set(row, 'sptm', '') + this.$set(row, 'dw', '') + this.$set(row, 'sl', undefined) + this.$set(row, 'dj', undefined) + this.$set(row, 'je', undefined) + this.$set(row, 'description', '') + this.$set(row, 'spxlhLoaded', false) + this.$set(row, 'xlhList', []) + return + } const product = this.spbhOptions.find(item => item.F_Id === row.spbh); console.log('选中商品', row.spbh, product); if (product) { diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue index f6a543c..64277a0 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/detail-view.vue @@ -162,7 +162,7 @@ - {{ labelFromOptions(detail.skzh, skzhOptions) }} + {{ displaySkzh() }} @@ -205,6 +205,7 @@ import { previewDataInterface } from '@/api/systemData/dataInterface' import { dynamicText } from '@/filters' import YsckdDetailView from '../wtYsckd/detail-view' import { getAccountSelector } from '@/api/extend/wtAccount' +import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay' export default { name: 'WtYsthdDetailView', @@ -267,6 +268,10 @@ export default { if (this.$refs.YsckdDetailView) this.$refs.YsckdDetailView.init(id) }) }, + displaySkzh() { + if (!this.detail) return '无' + return formatWtSkzhDisplay(this.detail, this.skzhOptions, { mode: 'refund' }) || '无' + }, labelFromOptions(value, options) { if (value === null || value === undefined || value === '') return '无' const opts = options || [] diff --git a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue index 7935f57..3775965 100644 --- a/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue +++ b/Antis.Erp.Plat/antis-ncc-admin/src/views/wtYsthd/index.vue @@ -94,8 +94,8 @@ - - + + - - + + - - - - + + + - - - - - - - + + + + + + - + @@ -166,6 +164,7 @@ import { previewDataInterface } from '@/api/systemData/dataInterface' import { promptApprovalRemark, postApproveGeneric } from '@/utils/wtRejectApproval' import { getAccountSelector } from '@/api/extend/wtAccount' + import { formatWtSkzhDisplay } from '@/utils/wtComboSkzhDisplay' export default { components: { NCCForm, DetailView, YsckdDetailView, ExportBox }, data() { @@ -283,6 +282,9 @@ this.skzhOptions = res.data.list }); }, + formatSkzhRow(row) { + return formatWtSkzhDisplay(row, this.skzhOptions, { mode: 'refund' }) + }, initData() { this.listLoading = true; let _query = { @@ -459,4 +461,9 @@ .ycddh-link { font-weight: normal; } +.wt-ysthd-table { + ::v-deep .el-table .cell { + white-space: nowrap; + } +} \ No newline at end of file diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs index 95b8d78..853118b 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs @@ -73,6 +73,48 @@ public class OrdersController : ControllerBase } /// + /// 拆分合并单:把这组订单从"同买家+同地址"的自动合并组中拆出来,分别单独发货。 + /// 入参 orderIds 传当前合并行里的所有子订单 id(即列表返回的 mergedOrderIds)。 + /// 已发货/已取消/已退款的订单会被忽略(只处理待发货的)。 + /// + [HttpPost("split")] + public async Task SplitMergedOrders([FromBody] SplitOrdersRequest request) + { + if (request?.OrderIds == null || request.OrderIds.Count == 0) + return BadRequest(new { message = "请传入要拆分的订单 id 列表(orderIds)" }); + try + { + var updated = await _orderService.SplitMergedOrdersAsync(request.OrderIds); + return Ok(new { message = $"已拆分 {updated} 笔订单", updated }); + } + catch (Exception ex) + { + _logger.LogError(ex, "拆分合并单失败: {Ids}", string.Join(",", request.OrderIds)); + return StatusCode(500, new { message = "拆分合并单失败", error = ex.Message }); + } + } + + /// + /// 恢复合并:取消"拆分"标记,下次列表会按同买家+同地址重新自动合并。 + /// + [HttpPost("unsplit")] + public async Task UnsplitOrders([FromBody] SplitOrdersRequest request) + { + if (request?.OrderIds == null || request.OrderIds.Count == 0) + return BadRequest(new { message = "请传入要恢复合并的订单 id 列表(orderIds)" }); + try + { + var updated = await _orderService.UnsplitOrdersAsync(request.OrderIds); + return Ok(new { message = $"已恢复 {updated} 笔订单", updated }); + } + catch (Exception ex) + { + _logger.LogError(ex, "恢复合并单失败: {Ids}", string.Join(",", request.OrderIds)); + return StatusCode(500, new { message = "恢复合并单失败", error = ex.Message }); + } + } + + /// /// 诊断订单合并情况(排查为何未合并) /// [HttpGet("debug/merge")] @@ -233,6 +275,16 @@ public class OrdersController : ControllerBase product_pic = itemObj["product_pic"]?.ToString() ?? itemObj["ProductPic"]?.ToString() ?? "", + // 抖音商品图(商品明细区展示);老发货单 JSON 里没有该字段,兜底用 product_pic + douyin_pic = itemObj["douyin_pic"]?.ToString() + ?? itemObj["DouyinPic"]?.ToString() + ?? itemObj["product_pic"]?.ToString() + ?? itemObj["ProductPic"]?.ToString() + ?? "", + // ERP 商品图(商品清单区展示);老发货单 JSON 里没有时为空,前端会在加载后按 spbm 再拉一次 + erp_pic = itemObj["erp_pic"]?.ToString() + ?? itemObj["ErpPic"]?.ToString() + ?? "", item_num = itemObj["item_num"]?.ToObject() ?? itemObj["ItemNum"]?.ToObject() ?? itemObj["itemNum"]?.ToObject() @@ -266,7 +318,14 @@ public class OrdersController : ControllerBase ?? false, isFromErp = itemObj["isFromErp"]?.ToObject() ?? itemObj["IsFromErp"]?.ToObject() - ?? false + ?? false, + // 已选序列号:编辑发货单时需要回显,避免用户重新打开弹窗时历史选择丢失 + selectedSerialNumbers = (itemObj["selectedSerialNumbers"] + ?? itemObj["SelectedSerialNumbers"] + ?? itemObj["serialNumbers"]) + is Newtonsoft.Json.Linq.JArray snArr + ? snArr.Select(x => x?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList() + : new List() }; // 记录ERP商品的调试信息 @@ -323,7 +382,14 @@ public class OrdersController : ControllerBase waybill = waybill != null ? new { cjck = waybill.Cjck, - status = waybill.Status + status = waybill.Status, + // 已发货时前端要回显物流单号/物流公司 + trackingNumber = waybill.TrackingNumber ?? "", + logisticsCompany = waybill.LogisticsCompany ?? "", + shipTime = waybill.ShipTime, + // 商家实际收入(元);null 表示还没改过,前端默认等于用户支付金额 + merchantIncome = waybill.MerchantIncome, + fhr = waybill.Fhr ?? "" } : null, productItems = productItems }); @@ -383,6 +449,14 @@ public class OrdersController : ControllerBase { product_name = item["product_name"]?.ToString(), product_pic = item["product_pic"]?.ToString(), + // 抖音图/ERP 图拆两个字段;老数据没有 douyin_pic 时兜底用 product_pic + douyin_pic = item["douyin_pic"]?.ToString() + ?? item["DouyinPic"]?.ToString() + ?? item["product_pic"]?.ToString() + ?? "", + erp_pic = item["erp_pic"]?.ToString() + ?? item["ErpPic"]?.ToString() + ?? "", item_num = item["item_num"]?.ToObject() ?? item["ItemNum"]?.ToObject() ?? 1, goods_price = item["goods_price"]?.ToObject() ?? item["GoodsPrice"]?.ToObject() ?? 0, spec = item["spec"], @@ -398,6 +472,21 @@ public class OrdersController : ControllerBase var orderIdsStr = string.Join(",", orders.Select(o => o.OrderId)); var allBuyerWords = string.Join("\n", orders.Where(o => !string.IsNullOrEmpty(o.BuyerWords)).Select(o => o.BuyerWords)); var allSellerWords = string.Join("\n", orders.Where(o => !string.IsNullOrEmpty(o.SellerWords)).Select(o => o.SellerWords)); + + // 合并订单也可能已经存在发货单(任意一条子订单的 id 都可能被当作 waybill.OrderId),编辑时需要回显已保存的商家实际收入/物流单号等 + Waybill? mergedWaybill = null; + try + { + mergedWaybill = await db.Queryable() + .Where(w => idList.Contains(w.OrderId)) + .OrderByDescending(w => w.CreateTime) + .FirstAsync(); + } + catch (Exception exw) + { + _logger.LogWarning(exw, "查询合并订单发货单失败: ids={Ids}", ids); + } + return Ok(new { order = new @@ -420,7 +509,16 @@ public class OrdersController : ControllerBase buyerWords = allBuyerWords, sellerWords = allSellerWords }, - waybill = (object?)null, + waybill = mergedWaybill != null ? (object)new + { + cjck = mergedWaybill.Cjck, + status = mergedWaybill.Status, + trackingNumber = mergedWaybill.TrackingNumber ?? "", + logisticsCompany = mergedWaybill.LogisticsCompany ?? "", + shipTime = mergedWaybill.ShipTime, + merchantIncome = mergedWaybill.MerchantIncome, + fhr = mergedWaybill.Fhr ?? "" + } : null, productItems }); } @@ -461,6 +559,10 @@ public class OrdersController : ControllerBase { try { + // 每次同步前先从 ERP 刷新一次店铺配置,保证 ERP 里的改动无需重启抖音服务即可生效 + try { await _douyinFactory.ReloadAsync(); } + catch (Exception reloadEx) { _logger.LogWarning(reloadEx, "同步前刷新店铺配置失败,继续使用当前已加载的配置"); } + await _orderService.SyncOrdersAsync(shopId); return Ok(new { message = "订单同步成功" }); } @@ -1140,17 +1242,23 @@ public class OrdersController : ControllerBase { if (item is Newtonsoft.Json.Linq.JObject itemObj) { - // 将productCode字段更新为转换后的商品ID(F_Id) itemObj["productCode"] = actualProductCode; - _logger.LogInformation("更新序列号数据中的productCode: {OriginalProductCode} -> {ActualProductCode}", - productCode, actualProductCode); } } } - - return Ok(new { code = 200, serialNumbers = serialNumbers }); + + // 注意:这里不能用 Ok(new { code = 200, serialNumbers = serialNumbers }) + // ASP.NET Core 默认用 System.Text.Json,会把 Newtonsoft 的 JObject 当作 + // IEnumerable 递归序列化,结果变成 [[[]],[[]],...] 多层嵌套空数组。 + // 改用 Newtonsoft.Json 直接序列化后原样写回,保证对象字段完整。 + var payload = JsonConvert.SerializeObject(new + { + code = 200, + serialNumbers = serialNumbersArray ?? new Newtonsoft.Json.Linq.JArray() + }); + return Content(payload, "application/json; charset=utf-8"); } - + // 如果没有找到序列号,返回空列表 return Ok(new { code = 200, serialNumbers = new List() }); } @@ -1292,6 +1400,7 @@ public class OrdersController : ControllerBase shopName = data["shopName"]?.ToString() ?? "", kh = data["kh"]?.ToString() ?? "", skzh = data["skzh"]?.ToString() ?? "", + ck = data["ck"]?.ToString() ?? "", bz = data["bz"]?.ToString() ?? "" } }); @@ -1532,7 +1641,7 @@ public class OrdersController : ControllerBase } /// - /// 获取收款账户列表(代理 ERP WtSkzhb 接口) + /// 获取收款账户列表(代理 ERP WtAccount 接口,wt_account 是账户主表) /// [HttpGet("payment-accounts")] public async Task GetPaymentAccounts() @@ -1543,7 +1652,7 @@ public class OrdersController : ControllerBase var httpClientFactory = HttpContext.RequestServices.GetRequiredService(); var httpClient = httpClientFactory.CreateClient(); - var apiUrl = $"{erpApiConfig.BaseUrl}/api/Extend/WtSkzhb?currentPage=1&pageSize=1000"; + var apiUrl = $"{erpApiConfig.BaseUrl}/api/Extend/WtAccount?currentPage=1&pageSize=1000&status=1"; var request = await _orderService.CreateAuthenticatedRequestAsync(System.Net.Http.HttpMethod.Get, apiUrl); var response = await httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) @@ -1558,8 +1667,17 @@ public class OrdersController : ControllerBase if (result?["data"] is JObject dataObj) { var list = dataObj["list"] as JArray ?? new JArray(); - var accountList = list.Select(item => item is JObject o ? o.ToObject>() : null) - .Where(x => x != null).ToList(); + var accountList = list.Select(item => + { + if (item is not JObject o) return null; + return (object)new + { + id = o["id"]?.ToString() ?? "", + accountName = o["accountName"]?.ToString() ?? "", + accountCode = o["accountCode"]?.ToString() ?? "", + category = o["category"]?.ToString() ?? "" + }; + }).Where(x => x != null).ToList(); return Ok(new { code = 200, data = accountList }); } return Ok(new { code = 200, data = new List() }); @@ -1572,6 +1690,62 @@ public class OrdersController : ControllerBase } /// + /// 获取 ERP 用户列表(用于发货单中"发货人"下拉),代理 ERP /api/permission/Users/All + /// + [HttpGet("users")] + public async Task GetUsers() + { + try + { + var erpApiConfig = HttpContext.RequestServices.GetRequiredService(); + var httpClientFactory = HttpContext.RequestServices.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient(); + + var apiUrl = $"{erpApiConfig.BaseUrl}/api/permission/Users/All"; + var request = await _orderService.CreateAuthenticatedRequestAsync(System.Net.Http.HttpMethod.Get, apiUrl); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errBody = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("ERP 用户列表接口返回 {StatusCode}: {Body}", response.StatusCode, errBody); + return StatusCode(500, new { message = "查询用户列表失败" }); + } + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(content); + // NCC 框架返回 { code, msg, data: [...] },其中 data 可能是数组或 { list: [...] } + JArray list = null; + var dataToken = result?["data"]; + if (dataToken is JArray arr) list = arr; + else if (dataToken is JObject obj && obj["list"] is JArray subArr) list = subArr; + list ??= new JArray(); + + var userList = list.Select(item => + { + if (item is not JObject o) return null; + // ERP 用户 Id 字段:兼容 id / F_Id + var id = o["id"]?.ToString() ?? o["F_Id"]?.ToString() ?? o["userId"]?.ToString() ?? ""; + var realName = o["realName"]?.ToString() ?? o["F_RealName"]?.ToString() ?? ""; + var account = o["account"]?.ToString() ?? o["F_Account"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(id)) return null; + return (object)new + { + id, + realName, + account, + displayName = !string.IsNullOrEmpty(realName) ? realName : (account ?? id) + }; + }).Where(x => x != null).ToList(); + return Ok(new { code = 200, data = userList }); + } + catch (Exception ex) + { + _logger.LogError(ex, "查询用户列表失败"); + return StatusCode(500, new { message = "查询用户列表失败", error = ex.Message }); + } + } + + /// /// 调试接口:查看发货单的原始ProductInfo数据 /// [HttpGet("debug/waybill-productinfo/{orderId}")] @@ -1952,6 +2126,35 @@ public class OrdersController : ControllerBase }); return Ok(new { data = shops }); } + + /// + /// 从 ERP(WtDyDpsz/internal/all)重新拉取抖音店铺配置并热替换。 + /// 在 ERP「抖音店铺设置」中新增/修改/停用店铺后调用,无需重启抖音服务。 + /// + [HttpPost("/api/shop-configs/reload")] + public async Task ReloadShopConfigs() + { + try + { + var count = await _douyinFactory.ReloadAsync(); + var shops = _douyinFactory.GetAllConfigs().Select(c => new + { + shopId = c.ShopId, + shopName = c.ShopName + }); + return Ok(new + { + message = $"已从 ERP 加载 {count} 个抖音店铺", + count, + data = shops + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "刷新抖音店铺配置失败"); + return StatusCode(500, new { message = "刷新抖音店铺配置失败", error = ex.Message }); + } + } } public class UpdateSellerRemarkRequest @@ -1959,6 +2162,14 @@ public class UpdateSellerRemarkRequest public string? SellerWords { get; set; } } +/// +/// 拆分 / 恢复合并单入参:orderIds 一般来自列表行的 mergedOrderIds。 +/// +public class SplitOrdersRequest +{ + public List OrderIds { get; set; } = new List(); +} + public class ManualShipRequest { /// 运单号 diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs index bca5c18..ca2d3a8 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs @@ -180,6 +180,12 @@ public class WaybillController : ControllerBase return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" }); } + // 发货人必填(写入 ERP 销售出库单 fhr) + if (string.IsNullOrWhiteSpace(request.Fhr)) + { + return BadRequest(new { message = "请先选择发货人" }); + } + var db = HttpContext.RequestServices.GetRequiredService(); // 检查是否已存在发货单(按订单ID查找最新的发货单) @@ -259,13 +265,30 @@ public class WaybillController : ControllerBase _logger.LogWarning("更新发货单商品信息 - ProductItems为空或数量为0: OrderId={OrderId}", request.OrderId); } existingWaybill.Remark = request.Remark ?? existingWaybill.Remark; + // 商家实际收入:前端每次提交都会带最新值;为空则保留原值 + if (request.MerchantIncome.HasValue) + existingWaybill.MerchantIncome = request.MerchantIncome.Value; + // 发货人:已在入口处校验非空 + existingWaybill.Fhr = request.Fhr; existingWaybill.UpdateTime = DateTime.Now; - // 商品明细变更时清除已关联的销售出库单,让后续流程重新创建 + // 发货单被重新编辑:先到 ERP 删掉旧的销售出库单,再清除本地关联,让后续流程生成新单 if (!string.IsNullOrEmpty(existingWaybill.SalesOrderId)) { - _logger.LogInformation("发货单商品明细已更新,清除旧销售出库单关联: WaybillId={WaybillId}, OldSalesOrderId={OldSalesOrderId}", - existingWaybill.Id, existingWaybill.SalesOrderId); + var oldSalesOrderId = existingWaybill.SalesOrderId; + _logger.LogInformation("发货单被重新编辑,准备删除 ERP 旧销售出库单: WaybillId={WaybillId}, OldSalesOrderId={OldSalesOrderId}", + existingWaybill.Id, oldSalesOrderId); + + var deleted = await _orderService.DeleteErpSalesOrderAsync(oldSalesOrderId); + if (!deleted) + { + // 旧单删不掉(通常是已被审核或已关联退货单),不能继续创建新单,否则 ERP 会出现重复出库 + return BadRequest(new + { + message = $"无法更新发货单:旧的销售出库单({oldSalesOrderId})在 ERP 中删除失败,可能已被审核或已关联退货单,请先到 ERP 处理后再试。" + }); + } + existingWaybill.SalesOrderId = null; } @@ -273,6 +296,7 @@ public class WaybillController : ControllerBase waybill = existingWaybill; waybillId = existingWaybill.Id; isUpdate = true; + DouyinLogistics.API.Services.OrderService.InvalidateOrderListCache(); _logger.LogInformation("更新发货单成功: WaybillId={WaybillId}, OrderId={OrderId}", waybillId, request.OrderId); } @@ -300,6 +324,8 @@ public class WaybillController : ControllerBase Cjck = request.Cjck, ProductInfo = "", Remark = request.Remark, + MerchantIncome = request.MerchantIncome, + Fhr = request.Fhr, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; @@ -337,6 +363,7 @@ public class WaybillController : ControllerBase } waybillId = await db.Insertable(waybill).ExecuteReturnIdentityAsync(); + DouyinLogistics.API.Services.OrderService.InvalidateOrderListCache(); _logger.LogInformation("手动创建发货单成功: WaybillId={WaybillId}, OrderId={OrderId}", waybillId, request.OrderId); } @@ -349,7 +376,8 @@ public class WaybillController : ControllerBase request.OrderId, request.DouyinOrderIds); var salesOrderResult = await _orderService.CreateSalesOrderAsync( request.OrderId, request.Kh, request.Skzh, request.Djlx, - request.DouyinOrderIds, request.MergedOrderInternalIds); + request.DouyinOrderIds, request.MergedOrderInternalIds, + request.MerchantIncome, request.Fhr); if (salesOrderResult.Success) { @@ -816,5 +844,13 @@ public class ManualCreateWaybillRequest public List? MergedOrderInternalIds { get; set; } public List? ProductItems { get; set; } public string? Remark { get; set; } + /// + /// 商家实际收入(元)。前端可修改,默认 = 用户支付金额;最终写入 ERP 销售出库单的 skje。 + /// + public decimal? MerchantIncome { get; set; } + /// + /// 发货人(ERP 用户 Id),必填。写入 ERP 销售出库单的 fhr 字段。 + /// + public string? Fhr { get; set; } } diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs index 10a0fcd..b5b9c6e 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs @@ -195,6 +195,12 @@ public class Order public string? OpenId { get; set; } /// + /// 是否已从合并组中拆出单独发货。true 表示用户手动拆分,列表不再按同买家+同地址合并此单。 + /// + [SugarColumn(ColumnDataType = "tinyint", IsNullable = false, DefaultValue = "0")] + public bool NoMerge { get; set; } + + /// /// 合并的订单ID列表(仅列表接口返回,不持久化。用于同人同地址合并时标识包含的订单) /// [SugarColumn(IsIgnore = true)] @@ -229,5 +235,11 @@ public class Order /// [SugarColumn(IsIgnore = true)] public bool IsPendingShipmentForm { get; set; } + + /// + /// 是否可以「恢复合并」:NoMerge=true 且同买家+同地址下仍存在其他待发货订单时为 true(仅列表接口返回,不持久化)。 + /// + [SugarColumn(IsIgnore = true)] + public bool CanUnsplit { get; set; } } diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs index 47c76f9..06cdc7e 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Waybill.cs @@ -151,5 +151,18 @@ public class Waybill /// [SugarColumn(Length = 100, IsNullable = true)] public string? SalesOrderId { get; set; } + + /// + /// 商家实际收入(元,可选)。用户可在编辑发货单时修改;空值表示按默认 = 用户支付金额。 + /// 提交 ERP 时会写入销售出库单的 skje(收款金额)。 + /// + [SugarColumn(ColumnDataType = "decimal(12,2)", IsNullable = true)] + public decimal? MerchantIncome { get; set; } + + /// + /// 发货人(ERP 用户 Id)。提交发货单时必填,会写入 ERP 销售出库单的 fhr 字段。 + /// + [SugarColumn(Length = 64, IsNullable = true)] + public string? Fhr { get; set; } } diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs index adc4952..72c348a 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Program.cs @@ -28,9 +28,13 @@ builder.Services.AddCors(options => // 配置 HttpClient builder.Services.AddHttpClient(); -// 配置抖音服务(支持多店铺:DouyinShops 数组) -var douyinShopConfigs = builder.Configuration.GetSection("DouyinShops").Get>() ?? new List(); -if (douyinShopConfigs.Count == 0) +// 内存缓存:用于订单列表分页的短期缓存,大幅降低翻页时的重复计算 +builder.Services.AddMemoryCache(); + +// 抖音店铺配置:首选来源 = ERP(WtDyDpsz/internal/all),兜底 = appsettings.json/DouyinShops +// 这样后续新开店铺时,只需要在 ERP 页面「抖音店铺设置」里维护,无需改配置文件和重启。 +var douyinShopFallback = builder.Configuration.GetSection("DouyinShops").Get>() ?? new List(); +if (douyinShopFallback.Count == 0) { // 兼容旧版单店配置 var douyinSection = builder.Configuration.GetSection("Douyin"); @@ -38,7 +42,7 @@ if (douyinShopConfigs.Count == 0) { long envShopId = 0; long.TryParse(Environment.GetEnvironmentVariable("DY_SHOP_ID"), out envShopId); - douyinShopConfigs.Add(new DouyinConfig + douyinShopFallback.Add(new DouyinConfig { SyncDays = douyinSection.GetValue("SyncDays") ?? 30, AppKey = Environment.GetEnvironmentVariable("DY_APP_KEY") ?? douyinSection["AppKey"] ?? string.Empty, @@ -57,12 +61,27 @@ if (douyinShopConfigs.Count == 0) }); } } + +// ShopConfigProvider:封装从 ERP 拉取店铺配置的逻辑(ERP 不可用时自动回退到 DouyinShops) +builder.Services.AddSingleton(sp => +{ + var httpFactory = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); + var erpCfg = sp.GetRequiredService(); + return new ShopConfigProvider(httpFactory, loggerFactory.CreateLogger(), erpCfg, douyinShopFallback); +}); + // 注册多店 Factory(Singleton,每个店一个 DouyinService 实例) +// 构造函数里不立即加载,由启动钩子和 reload 接口驱动 builder.Services.AddSingleton(sp => { var httpFactory = sp.GetRequiredService(); var loggerFactory = sp.GetRequiredService(); - return new DouyinServiceFactory(douyinShopConfigs, httpFactory, loggerFactory); + var provider = sp.GetRequiredService(); + var factory = new DouyinServiceFactory(httpFactory, loggerFactory, provider); + // 先用兜底配置填充一份,避免极早请求到达时 factory 为空 + factory.ReplaceAll(douyinShopFallback); + return factory; }); // 配置顺丰服务 @@ -185,7 +204,8 @@ _ = Task.Run(async () => { "ProductItems", "text NULL COMMENT '商品明细(JSON格式,包含所有商品信息)'" }, { "BuyerWords", "varchar(500) NULL COMMENT '买家留言'" }, { "SellerWords", "varchar(500) NULL COMMENT '卖家备注'" }, - { "LogisticsCode", "varchar(50) NULL COMMENT '物流公司代码(抖音company_code)'" } + { "LogisticsCode", "varchar(50) NULL COMMENT '物流公司代码(抖音company_code)'" }, + { "NoMerge", "tinyint NOT NULL DEFAULT 0 COMMENT '是否手动拆分出合并组:1=不参与自动合并'" } }; foreach (var field in fieldsToAdd) @@ -228,7 +248,9 @@ _ = Task.Run(async () => { { "ShopId", "bigint NULL COMMENT '所属店铺ID(抖音ShopId)'" }, { "Cjck", "varchar(100) NULL COMMENT '出库仓库(门店ID)'" }, - { "SalesOrderId", "varchar(100) NULL COMMENT '销售出库单ID(ERP系统中的出库单ID)'" } + { "SalesOrderId", "varchar(100) NULL COMMENT '销售出库单ID(ERP系统中的出库单ID)'" }, + { "MerchantIncome", "decimal(12,2) NULL COMMENT '商家实际收入(元)。空值表示按用户支付金额默认'" }, + { "Fhr", "varchar(64) NULL COMMENT '发货人(ERP 用户 Id),提交销售出库单时写入 ERP fhr 字段'" } }; foreach (var field in fieldsToAdd) @@ -300,9 +322,11 @@ CREATE TABLE IF NOT EXISTS `waybills` ( } // 历史数据刷 ShopId:把 ShopId 为空的订单/运单归属到第一个配置的店铺 - if (douyinShopConfigs.Count > 0) + var factoryForHistory = app.Services.GetRequiredService(); + var historyCfgs = factoryForHistory.GetAllConfigs(); + if (historyCfgs.Count > 0) { - var defaultCfg = douyinShopConfigs[0]; + var defaultCfg = historyCfgs[0]; var updOrders = db.Ado.ExecuteCommand( "UPDATE `orders` SET ShopId = @sid, ShopName = @sname WHERE ShopId IS NULL", new { sid = defaultCfg.ShopId, sname = defaultCfg.ShopName }); @@ -321,4 +345,20 @@ CREATE TABLE IF NOT EXISTS `waybills` ( } }); +// 启动时异步从 ERP 拉取抖音店铺配置并热替换 Factory(ERP 不可用时已经有 appsettings 兜底) +_ = Task.Run(async () => +{ + await Task.Delay(1500); + try + { + var factory = app.Services.GetRequiredService(); + var count = await factory.ReloadAsync(); + Console.WriteLine($"✅ 启动时已从 ERP 加载 {count} 个抖音店铺配置"); + } + catch (Exception ex) + { + Console.WriteLine($"⚠️ 启动时从 ERP 加载抖音店铺配置失败(已使用 appsettings 兜底): {ex.Message}"); + } +}); + app.Run(); diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs index a76fa6b..c024d7c 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs @@ -24,6 +24,23 @@ public class DouyinService /// 接口未返回 expires_in 时采用的默认剩余秒数(约 2 小时)。 private const int DefaultExpiresInWhenUnknownSeconds = 7200; + /// 北京时区(UTC+8)。用于把抖音返回的 Unix 时间戳转换为业务所需的北京时间。 + private static readonly TimeZoneInfo BeijingTimeZone = ResolveBeijingTimeZone(); + + private static TimeZoneInfo ResolveBeijingTimeZone() + { + // .NET 6+ 在 Windows/Linux/macOS 均支持 "Asia/Shanghai";异常时兜底 Windows 标识 + try { return TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai"); } + catch { return TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"); } + } + + /// 把 Unix 秒时间戳转换为北京时间(Kind=Unspecified,便于直接写入 MySQL DATETIME) + private static DateTime UnixSecondsToBeijingTime(long seconds) + { + var utc = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; + return TimeZoneInfo.ConvertTimeFromUtc(utc, BeijingTimeZone); + } + public DouyinService(DouyinConfig config, IHttpClientFactory httpClientFactory, ILogger logger) { _config = config; @@ -483,22 +500,22 @@ public class DouyinService } } - // 抖音下单时间(create_time,秒或毫秒) + // 抖音下单时间(create_time,秒或毫秒)→ 转北京时间 var createTimeObj = orderObj["create_time"]; if (createTimeObj != null && long.TryParse(createTimeObj.ToString(), out var createTimeRaw)) { var sec = createTimeRaw > 1_000_000_000_000L ? createTimeRaw / 1000 : createTimeRaw; if (sec > 0) - order.DouyinOrderTime = DateTimeOffset.FromUnixTimeSeconds(sec).DateTime; + order.DouyinOrderTime = UnixSecondsToBeijingTime(sec); } - // 提取付款时间(秒或毫秒) + // 提取付款时间(秒或毫秒)→ 转北京时间 var payTimeObj = orderObj["pay_time"]; if (payTimeObj != null && long.TryParse(payTimeObj.ToString(), out var payTimeRaw)) { var paySec = payTimeRaw > 1_000_000_000_000L ? payTimeRaw / 1000 : payTimeRaw; if (paySec > 0) - order.PayTime = DateTimeOffset.FromUnixTimeSeconds(paySec).DateTime; + order.PayTime = UnixSecondsToBeijingTime(paySec); } // 提取订单金额和实付金额 diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs index b42046f..dc25c42 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinServiceFactory.cs @@ -5,21 +5,75 @@ namespace DouyinLogistics.API.Services; /// /// 管理多个抖店的 DouyinService 实例(每个 ShopId 一个),Singleton 注册。 +/// 配置来源:启动时由 Program.cs 通过 ShopConfigProvider 从 ERP 拉取, +/// 运行时可以通过 ReloadAsync 重新拉取、热替换。 /// public class DouyinServiceFactory { private readonly ConcurrentDictionary _services = new(); - private readonly List _configs; + private List _configs; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly ShopConfigProvider _shopConfigProvider; + private readonly ILogger _logger; + private readonly SemaphoreSlim _reloadLock = new(1, 1); - public DouyinServiceFactory(IEnumerable configs, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory) + public DouyinServiceFactory( + IHttpClientFactory httpClientFactory, + ILoggerFactory loggerFactory, + ShopConfigProvider shopConfigProvider) { - _configs = configs.ToList(); - foreach (var cfg in _configs) + _httpClientFactory = httpClientFactory; + _loggerFactory = loggerFactory; + _shopConfigProvider = shopConfigProvider; + _logger = loggerFactory.CreateLogger(); + _configs = new List(); + } + + /// + /// 使用指定配置列表初始化/重建所有 DouyinService 实例。 + /// 现有实例会被替换,实现热更新。 + /// + public void ReplaceAll(IEnumerable configs) + { + var list = configs?.ToList() ?? new List(); + _configs = list; + var newIds = new HashSet(list.Select(c => c.ShopId)); + + foreach (var cfg in list) { - var logger = loggerFactory.CreateLogger(); - var svc = new DouyinService(cfg, httpClientFactory, logger); + if (cfg.ShopId <= 0) continue; + var svc = new DouyinService(cfg, _httpClientFactory, _loggerFactory.CreateLogger()); _services[cfg.ShopId] = svc; } + + foreach (var key in _services.Keys.ToList()) + { + if (!newIds.Contains(key)) + { + _services.TryRemove(key, out _); + } + } + _logger.LogInformation("✅ DouyinServiceFactory 已加载 {Count} 个店铺:{Ids}", _services.Count, + string.Join(", ", _services.Keys)); + } + + /// + /// 从 ERP 刷新店铺配置并热替换服务实例。 + /// + public async Task ReloadAsync(CancellationToken ct = default) + { + await _reloadLock.WaitAsync(ct); + try + { + var fresh = await _shopConfigProvider.FetchAllAsync(ct); + ReplaceAll(fresh); + return _services.Count; + } + finally + { + _reloadLock.Release(); + } } /// 按 ShopId 获取对应的 DouyinService;找不到则返回 null。 @@ -32,6 +86,8 @@ public class DouyinServiceFactory /// 获取第一个(默认)DouyinService(兼容单店调用场景)。 public DouyinService GetDefaultService() { + if (_services.IsEmpty) + throw new InvalidOperationException("尚未加载任何抖音店铺配置,请到 ERP 抖音店铺设置中配置后,POST /api/shop-configs/reload 重新加载。"); return _services.Values.First(); } diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs index 1eaad6b..31dc02d 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs @@ -1,4 +1,5 @@ using DouyinLogistics.API.Models; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using SqlSugar; using System.Text; @@ -33,10 +34,17 @@ public class OrderService private readonly HttpClient _httpClient; private readonly ErpApiConfig _erpApiConfig; private readonly IServiceScopeFactory _scopeFactory; + private readonly IMemoryCache _cache; private static string? _erpToken = null; private static DateTime _tokenExpireTime = DateTime.MinValue; private static readonly object _tokenLock = new object(); + // 订单列表缓存 TTL:短 TTL 让翻页秒开,又保证订单同步/状态变更后很快可见 + private static readonly TimeSpan OrderListCacheTtl = TimeSpan.FromSeconds(15); + // 订单列表缓存版本,用于在订单/发货单写入后强制失效(修改时 Interlocked.Increment) + private static long _orderListCacheVersion = 0; + private const string OrderListCachePrefix = "orders:list:"; + public OrderService( ISqlSugarClient db, DouyinServiceFactory douyinFactory, @@ -45,7 +53,8 @@ public class OrderService ILogger logger, IHttpClientFactory httpClientFactory, ErpApiConfig erpApiConfig, - IServiceScopeFactory scopeFactory) + IServiceScopeFactory scopeFactory, + IMemoryCache cache) { _db = db; _douyinFactory = douyinFactory; @@ -55,6 +64,15 @@ public class OrderService _httpClient = httpClientFactory.CreateClient(); _erpApiConfig = erpApiConfig; _scopeFactory = scopeFactory; + _cache = cache; + } + + /// + /// 失效订单列表缓存;订单同步、状态变更、发货单创建/更新后调用,避免用户看到过期数据 + /// + public static void InvalidateOrderListCache() + { + System.Threading.Interlocked.Increment(ref _orderListCacheVersion); } /// @@ -465,6 +483,8 @@ public class OrderService { _logger.LogInformation("订单同步完成 - 新增: {NewCount}, 更新: {UpdateCount}, 总计: {TotalCount}, 总耗时: {TotalElapsed} 秒", newOrderCount, updateOrderCount, douyinOrders.Count, totalElapsed.ToString("F2")); + // 有新增/更新:让下一次列表请求立刻感知变化 + InvalidateOrderListCache(); } else { @@ -660,23 +680,47 @@ public class OrderService query = query.Where(o => o.DouyinOrderTime != null && o.DouyinOrderTime <= douyinOrderTimeEnd.Value.AddDays(1).AddSeconds(-1)); } - // 所有状态都参与合并(按买家+地址分组),已退款/退款中/已取消从合并组中剔除 - var allOrders = await query - .OrderBy("COALESCE(PayTime, DouyinOrderTime, CreateTime) ASC") - .ToListAsync(); - var merged = MergeOrdersByBuyerAndAddress(allOrders); - if (pendingShipmentForm || hasWaybill.HasValue) - merged = await FilterMergedOrdersPostProcessAsync(merged, hasWaybill, pendingShipmentForm); - merged = await EnrichMergedShipmentFormStateAsync(merged); - if (shipmentFormSubmitted == true) - { - merged = merged - .Where(m => m.Status == 1 && m.HasSubmittedShipmentForm) - .ToList(); + // 过滤条件完全一致时,10~15 秒内的重复翻页请求命中缓存,避免重复拉全表 + 重复合并 + var cacheKey = BuildOrderListCacheKey( + shopId, status, orderId, receiverName, receiverPhone, trackingNumber, productName, + hasWaybill, pendingShipmentForm, createTimeStart, createTimeEnd, + payTimeStart, payTimeEnd, douyinOrderTimeStart, douyinOrderTimeEnd, + shipmentFormSubmitted); + + List merged; + if (_cache.TryGetValue>(cacheKey, out var cached) && cached != null) + { + merged = cached; } - merged = ApplyMergedOrderSort(merged, sortBy, sortOrder); - var total = merged.Count; - var paged = merged + else + { + // 所有状态都参与合并(按买家+地址分组),已退款/退款中/已取消从合并组中剔除 + var allOrders = await query + .OrderBy("COALESCE(PayTime, DouyinOrderTime, CreateTime) ASC") + .ToListAsync(); + merged = MergeOrdersByBuyerAndAddress(allOrders); + + // 合并 Waybill 查询:以前会分别在 FilterMergedOrdersPostProcessAsync、EnrichMergedShipmentFormStateAsync 里各查一次 + // 这里一次性查好,两个逻辑共用,减少一次 WHERE IN (几百~几千 ID) 的重 SQL + merged = await EnrichAndFilterMergedAsync(merged, hasWaybill, pendingShipmentForm); + if (shipmentFormSubmitted == true) + { + merged = merged + .Where(m => m.Status == 1 && m.HasSubmittedShipmentForm) + .ToList(); + } + + _cache.Set(cacheKey, merged, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = OrderListCacheTtl, + Size = merged.Count + }); + } + + // 排序和分页保持在内存里做,命中缓存时也能按不同的 sortBy/pageIndex 快速响应 + var sorted = ApplyMergedOrderSort(merged, sortBy, sortOrder); + var total = sorted.Count; + var paged = sorted .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToList(); @@ -684,6 +728,93 @@ public class OrderService } /// + /// 构造订单列表缓存 Key:包含所有影响"候选结果集"的过滤条件和版本号。 + /// 不包含 sortBy/sortOrder/pageIndex/pageSize,这些在内存里处理。 + /// + private static string BuildOrderListCacheKey( + long? shopId, int? status, string? orderId, string? receiverName, string? receiverPhone, + string? trackingNumber, string? productName, bool? hasWaybill, bool pendingShipmentForm, + DateTime? createTimeStart, DateTime? createTimeEnd, + DateTime? payTimeStart, DateTime? payTimeEnd, + DateTime? douyinOrderTimeStart, DateTime? douyinOrderTimeEnd, + bool? shipmentFormSubmitted) + { + string F(DateTime? d) => d?.ToString("yyyyMMddHHmmss") ?? ""; + return string.Concat( + OrderListCachePrefix, + System.Threading.Interlocked.Read(ref _orderListCacheVersion), "|", + shopId?.ToString() ?? "", "|", + status?.ToString() ?? "", "|", + orderId?.Trim() ?? "", "|", + receiverName?.Trim() ?? "", "|", + receiverPhone?.Trim() ?? "", "|", + trackingNumber?.Trim() ?? "", "|", + productName?.Trim() ?? "", "|", + hasWaybill?.ToString() ?? "", "|", + pendingShipmentForm, "|", + F(createTimeStart), "|", F(createTimeEnd), "|", + F(payTimeStart), "|", F(payTimeEnd), "|", + F(douyinOrderTimeStart), "|", F(douyinOrderTimeEnd), "|", + shipmentFormSubmitted?.ToString() ?? ""); + } + + /// + /// 合并 FilterMergedOrdersPostProcessAsync + EnrichMergedShipmentFormStateAsync 的逻辑: + /// 只查一次 Waybill 表,同时完成过滤(hasWaybill / pendingShipmentForm)和状态补充(HasSubmittedShipmentForm / IsPendingShipmentForm)。 + /// + private async Task> EnrichAndFilterMergedAsync( + List merged, bool? hasWaybill, bool pendingShipmentForm) + { + if (merged == null || merged.Count == 0) + return merged ?? new List(); + + var allIds = merged + .SelectMany(m => m.MergedOrderIds != null && m.MergedOrderIds.Count > 0 + ? m.MergedOrderIds + : new List { m.Id }) + .Distinct() + .ToList(); + + var wbRows = await _db.Queryable() + .Where(w => allIds.Contains(w.OrderId)) + .Select(w => new { w.OrderId, w.SalesOrderId }) + .ToListAsync(); + + var hasWaybillSet = new HashSet(wbRows.Select(w => w.OrderId)); + var submittedSet = new HashSet(wbRows + .Where(w => !string.IsNullOrWhiteSpace(w.SalesOrderId)) + .Select(w => w.OrderId)); + + var result = new List(merged.Count); + foreach (var m in merged) + { + var ids = m.MergedOrderIds != null && m.MergedOrderIds.Count > 0 + ? m.MergedOrderIds + : new List { m.Id }; + + var hasWb = ids.Any(id => hasWaybillSet.Contains(id)) || !string.IsNullOrWhiteSpace(m.TrackingNumber); + var hasSubmitted = ids.Any(id => submittedSet.Contains(id)); + + // 过滤 + if (pendingShipmentForm) + { + if (!hasWb || hasSubmitted) continue; + } + else if (hasWaybill.HasValue) + { + if (hasWaybill.Value != hasWb) continue; + } + + // 补充状态字段 + m.HasSubmittedShipmentForm = hasSubmitted; + m.IsPendingShipmentForm = m.Status == 1 && hasWb && !hasSubmitted; + result.Add(m); + } + + return result; + } + + /// /// 合并订单规则: /// 1. 待发货(0):同买家同地址 → 合并(用于一起发货) /// 2. 已发货(1):同运单号 → 合并(之前合并发货的继续合并展示,分开发货的分开展示) @@ -711,7 +842,25 @@ public class OrderService result.AddRange(refundingOrders); // 规则1:待发货按同买家同地址合并 - var pendingGroups = pendingOrders + // 用户手动标记 NoMerge=true 的订单不参与合并,单独显示(走 "拆分" 按钮把订单从合并组里拆出来) + var pendingNoMerge = pendingOrders.Where(o => o.NoMerge).ToList(); + var pendingMergeable = pendingOrders.Where(o => !o.NoMerge).ToList(); + + // 对每个 NoMerge 订单判断是否还"能恢复合并": + // - 同买家+同地址下,待发货订单总数(含自己、含其他 NoMerge)>=2 才有合并价值 + // - 只剩它一个待发货的(伙伴都发货/取消了)就不显示"恢复合并"按钮 + var pendingKeyCount = pendingOrders + .GroupBy(o => GetBuyerAddressKey(o)) + .ToDictionary(g => g.Key, g => g.Count()); + foreach (var o in pendingNoMerge) + { + var cnt = pendingKeyCount.TryGetValue(GetBuyerAddressKey(o), out var n) ? n : 1; + o.CanUnsplit = cnt >= 2; + } + + result.AddRange(pendingNoMerge); + + var pendingGroups = pendingMergeable .GroupBy(o => GetBuyerAddressKey(o)) .ToList(); foreach (var g in pendingGroups) @@ -726,10 +875,11 @@ public class OrderService } // 规则2:已发货按同运单号合并(只有共享运单号的才是一起发货的) - var shippedWithTracking = shippedOrders.Where(o => !string.IsNullOrWhiteSpace(o.TrackingNumber)).ToList(); - var shippedNoTracking = shippedOrders.Where(o => string.IsNullOrWhiteSpace(o.TrackingNumber)).ToList(); + // 已拆分的订单(NoMerge=true)也走单独显示,避免历史标记影响已发货展示 + var shippedWithTracking = shippedOrders.Where(o => !string.IsNullOrWhiteSpace(o.TrackingNumber) && !o.NoMerge).ToList(); + var shippedNoTracking = shippedOrders.Where(o => string.IsNullOrWhiteSpace(o.TrackingNumber) || o.NoMerge).ToList(); - // 无运单号的已发货订单单独显示 + // 无运单号 / 已被用户拆分的已发货订单单独显示 result.AddRange(shippedNoTracking); // 有运单号的按运单号分组 @@ -753,6 +903,67 @@ public class OrderService .ToList(); } + /// + /// 拆分合并单:把指定订单 id 集合上的 NoMerge 置 true,后续列表不再把它们合并到同买家+同地址的组里。 + /// 已发货订单(status=1)不应拆分:合并是按运单号识别,拆了也没实际意义,这里直接忽略。 + /// + /// 实际被更新的订单数量 + public async Task SplitMergedOrdersAsync(IEnumerable orderIds) + { + var ids = orderIds?.Distinct().ToList() ?? new List(); + if (ids.Count == 0) return 0; + + var rows = await _db.Queryable() + .Where(o => ids.Contains(o.Id)) + .ToListAsync(); + + // 只对「待发货/未拆分」订单做拆分;已拆分/已发货/已取消/已退款一律不动 + var toUpdate = rows + .Where(o => !o.NoMerge && o.Status == 0) + .ToList(); + if (toUpdate.Count == 0) return 0; + + foreach (var o in toUpdate) + { + o.NoMerge = true; + o.UpdateTime = DateTime.Now; + } + + await _db.Updateable(toUpdate) + .UpdateColumns(o => new { o.NoMerge, o.UpdateTime }) + .ExecuteCommandAsync(); + + InvalidateOrderListCache(); + return toUpdate.Count; + } + + /// + /// 恢复合并:把指定订单的 NoMerge 置 false,下次列表按同买家+同地址重新合并。 + /// + public async Task UnsplitOrdersAsync(IEnumerable orderIds) + { + var ids = orderIds?.Distinct().ToList() ?? new List(); + if (ids.Count == 0) return 0; + + var rows = await _db.Queryable() + .Where(o => ids.Contains(o.Id) && o.NoMerge) + .ToListAsync(); + if (rows.Count == 0) return 0; + + foreach (var o in rows) + { + o.NoMerge = false; + o.UpdateTime = DateTime.Now; + } + + await _db.Updateable(rows) + .UpdateColumns(o => new { o.NoMerge, o.UpdateTime }) + .ExecuteCommandAsync(); + + InvalidateOrderListCache(); + return rows.Count; + } + private string GetBuyerAddressKey(Order o) { var shopPart = (o.ShopId ?? 0L).ToString(); @@ -792,6 +1003,34 @@ public class OrderService first.MergedOrderStatuses = list.Select(o => o.Status).ToList(); first.PayAmount = list.Sum(o => o.PayAmount ?? 0); first.OrderAmount = list.Sum(o => o.OrderAmount ?? 0); + + // 合并所有订单的买家留言和卖家备注(每个订单一行,带订单号前缀) + if (list.Count > 1) + { + var buyerLines = list + .Where(o => !string.IsNullOrWhiteSpace(o.BuyerWords)) + .Select(o => + { + var tail = !string.IsNullOrEmpty(o.OrderId) && o.OrderId.Length >= 6 + ? o.OrderId.Substring(o.OrderId.Length - 6) + : (o.OrderId ?? ""); + return $"[{tail}] {o.BuyerWords}"; + }) + .ToList(); + first.BuyerWords = buyerLines.Count > 0 ? string.Join("\n", buyerLines) : ""; + + var sellerLines = list + .Where(o => !string.IsNullOrWhiteSpace(o.SellerWords)) + .Select(o => + { + var tail = !string.IsNullOrEmpty(o.OrderId) && o.OrderId.Length >= 6 + ? o.OrderId.Substring(o.OrderId.Length - 6) + : (o.OrderId ?? ""); + return $"[{tail}] {o.SellerWords}"; + }) + .ToList(); + first.SellerWords = sellerLines.Count > 0 ? string.Join("\n", sellerLines) : ""; + } var douyinTimes = list.Where(x => x.DouyinOrderTime.HasValue).Select(x => x.DouyinOrderTime!.Value).ToList(); if (douyinTimes.Count > 0) first.DouyinOrderTime = douyinTimes.Min(); @@ -1408,7 +1647,9 @@ public class OrderService string? overrideSkzh = null, string? djlx = null, string? douyinOrderIds = null, - List? mergedOrderInternalIds = null) + List? mergedOrderInternalIds = null, + decimal? merchantIncome = null, + string? fhr = null) { var result = new CreateSalesOrderResult(); var finalDjlx = string.IsNullOrEmpty(djlx) ? "销售出库单" : djlx; @@ -1488,30 +1729,30 @@ public class OrderService return result; } - // 4. 获取出库仓库 + // 4. 获取出库仓库(优先级:发货单 cjck > 抖音店铺设置 ck;不再兜底全局默认仓库,避免误发) if (waybill != null && !string.IsNullOrEmpty(waybill.Cjck)) { warehouseId = waybill.Cjck; _logger.LogInformation("从发货单获取出库仓库: OrderId={OrderId}, WarehouseId={WarehouseId}", orderId, warehouseId); } - else + else if (order.ShopId.HasValue) { try { - var warehouseApiUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/WtMd?currentPage=1&pageSize=1"; - var warehouseRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Get, warehouseApiUrl); - var warehouseResponse = await _httpClient.SendAsync(warehouseRequest); - if (warehouseResponse.IsSuccessStatusCode) + var shopCkUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/WtDyDpsz/by-shop/{order.ShopId.Value}"; + var shopCkReq = await CreateAuthenticatedRequestAsync(HttpMethod.Get, shopCkUrl); + var shopCkRes = await _httpClient.SendAsync(shopCkReq); + if (shopCkRes.IsSuccessStatusCode) { - var warehouseContent = await warehouseResponse.Content.ReadAsStringAsync(); - var warehouseResult = JsonConvert.DeserializeObject(warehouseContent); - if (warehouseResult != null && warehouseResult["code"]?.ToString() == "200") + var shopCkContent = await shopCkRes.Content.ReadAsStringAsync(); + var shopCkRoot = JsonConvert.DeserializeObject(shopCkContent); + if (shopCkRoot != null && shopCkRoot["code"]?.ToString() == "200") { - var warehouseList = warehouseResult["data"]?["list"] as JArray; - if (warehouseList != null && warehouseList.Count > 0) + var shopCk = shopCkRoot["data"]?["ck"]?.ToString(); + if (!string.IsNullOrEmpty(shopCk)) { - warehouseId = warehouseList[0]?["id"]?.ToString() ?? ""; - _logger.LogInformation("获取默认门店ID: OrderId={OrderId}, WarehouseId={WarehouseId}", orderId, warehouseId); + warehouseId = shopCk; + _logger.LogInformation("从抖音店铺设置获取出库仓库: ShopId={ShopId}, WarehouseId={WarehouseId}", order.ShopId.Value, warehouseId); } } } @@ -1603,68 +1844,113 @@ public class OrderService return result; } - // 5.5 自动库存校验:解析完商品后调用 BatchCheckStock,库存不足自动切换为预售出库单 + // 5.5 库存校验:抖音发货只支持销售出库单(不再自动转预售) + // 只要有商品库存不足或缺少商品编码,直接报错,让用户去 ERP 补货或在抖音侧取消发货 if (finalDjlx == "销售出库单" && !string.IsNullOrEmpty(warehouseId)) { - // 没有 spbm(商品编码)的商品无法通过 API 校验库存,直接视为库存不可用 var itemsWithoutSpbm = resolvedItems.Where(r => string.IsNullOrEmpty(r.sptm)).ToList(); if (itemsWithoutSpbm.Count > 0) { - finalDjlx = "预售出库单"; var names = string.Join("、", itemsWithoutSpbm.Select(r => r.spmc)); - _logger.LogInformation("商品无编码(spbm)无法校验库存,自动切换为预售出库单: OrderId={OrderId}, 商品={Names}", orderId, names); + _logger.LogWarning("商品无编码(spbm)无法校验库存,拒绝发货: OrderId={OrderId}, 商品={Names}", orderId, names); + result.ErrorMessage = $"以下商品未关联 ERP 商品编码,无法校验库存,请先在 ERP 商品档案补齐:{names}"; + return result; } - else + + try { - try + var stockItems = resolvedItems + .Select(r => new { spbm = r.sptm, quantity = r.sl }) + .ToList(); + + var stockPayload = new { storeId = warehouseId, items = stockItems }; + var stockJson = JsonConvert.SerializeObject(stockPayload); + var stockContent = new StringContent(stockJson, Encoding.UTF8, "application/json"); + var stockUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/wtsp/BatchCheckStock"; + var stockRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Post, stockUrl, stockContent); + var stockResponse = await _httpClient.SendAsync(stockRequest); + + if (!stockResponse.IsSuccessStatusCode) { - var stockItems = resolvedItems - .Select(r => new { spbm = r.sptm, quantity = r.sl }) - .ToList(); - - var stockPayload = new { storeId = warehouseId, items = stockItems }; - var stockJson = JsonConvert.SerializeObject(stockPayload); - var stockContent = new StringContent(stockJson, Encoding.UTF8, "application/json"); - var stockUrl = $"{_erpApiConfig.BaseUrl}/api/Extend/wtsp/BatchCheckStock"; - var stockRequest = await CreateAuthenticatedRequestAsync(HttpMethod.Post, stockUrl, stockContent); - var stockResponse = await _httpClient.SendAsync(stockRequest); - - if (stockResponse.IsSuccessStatusCode) - { - var stockResponseContent = await stockResponse.Content.ReadAsStringAsync(); - var stockResult = JsonConvert.DeserializeObject(stockResponseContent); - var stockApiOk = stockResult?["success"]?.ToObject() ?? false; - if (!stockApiOk) - { - var errMsg = stockResult?["msg"]?.ToString() ?? ""; - _logger.LogWarning("库存校验返回失败: OrderId={OrderId}, Msg={Msg}", orderId, errMsg); - finalDjlx = "预售出库单"; - } - else - { - // 缺省按「不充足」处理,避免缺字段时误判为销售出库单 - var allSufficient = stockResult?["allSufficient"]?.ToObject() ?? false; - if (!allSufficient) - { - finalDjlx = "预售出库单"; - _logger.LogInformation("库存不足,自动切换为预售出库单: OrderId={OrderId}", orderId); - } - else - { - _logger.LogInformation("库存充足,使用销售出库单: OrderId={OrderId}", orderId); - } - } - } - else + _logger.LogWarning("库存校验API返回非成功状态: StatusCode={StatusCode}, OrderId={OrderId}", + stockResponse.StatusCode, orderId); + result.ErrorMessage = $"调用 ERP 库存校验接口失败(HTTP {(int)stockResponse.StatusCode}),请稍后重试"; + return result; + } + + var stockResponseContent = await stockResponse.Content.ReadAsStringAsync(); + _logger.LogDebug("BatchCheckStock 原始返回: OrderId={OrderId}, Body={Body}", orderId, stockResponseContent); + var stockResult = JsonConvert.DeserializeObject(stockResponseContent); + + // NCC 动态 API 会把 Controller 返回值再包一层 { code, msg, data }: + // - 外层:{ code: 200, msg: "操作成功", data: { success: true, data: [...], allSufficient: true } } + // - 这里只关心真正 BatchCheckStock 返回的那层(payload),并同时兼容没有包装时的裸返回 + JObject? payload = stockResult; + var ncCode = stockResult?["code"]?.ToObject(); + if (ncCode.HasValue) + { + if (ncCode.Value != 200) { - _logger.LogWarning("库存校验API返回非成功状态: StatusCode={StatusCode}, OrderId={OrderId}", - stockResponse.StatusCode, orderId); + var ncMsg = stockResult?["msg"]?.ToString() ?? "调用 ERP 库存校验接口失败"; + _logger.LogWarning("库存校验API返回异常: OrderId={OrderId}, Code={Code}, Msg={Msg}", orderId, ncCode.Value, ncMsg); + result.ErrorMessage = $"调用 ERP 库存校验接口失败:{ncMsg}"; + return result; } + payload = stockResult?["data"] as JObject; + } + + var stockApiOk = payload?["success"]?.ToObject() ?? false; + if (!stockApiOk) + { + var errMsg = payload?["msg"]?.ToString() ?? "库存校验失败"; + _logger.LogWarning("库存校验返回失败: OrderId={OrderId}, Msg={Msg}", orderId, errMsg); + result.ErrorMessage = $"库存校验失败:{errMsg}"; + return result; } - catch (Exception ex) + + var allSufficient = payload?["allSufficient"]?.ToObject() ?? false; + if (!allSufficient) { - _logger.LogWarning(ex, "库存校验失败,继续使用{Djlx}: OrderId={OrderId}", finalDjlx, orderId); + // 把具体缺货的商品名和库存信息拼出来返回给前端 + var insufficientList = payload?["data"] as JArray; + var insufficientMsgs = new List(); + if (insufficientList != null) + { + foreach (var it in insufficientList) + { + var suf = it?["sufficient"]?.ToObject() ?? true; + if (suf) continue; + // 对齐 ERP BatchCheckStock 的字段:productName / available / required + var name = it?["productName"]?.ToString() + ?? it?["spmc"]?.ToString() + ?? it?["spbm"]?.ToString() + ?? ""; + var stock = it?["available"]?.ToString() + ?? it?["stock"]?.ToString() + ?? "0"; + var need = it?["required"]?.ToString() + ?? it?["quantity"]?.ToString() + ?? ""; + insufficientMsgs.Add(string.IsNullOrEmpty(need) + ? $"{name}(库存 {stock})" + : $"{name}(库存 {stock},需要 {need})"); + } + } + var detail = insufficientMsgs.Count > 0 ? string.Join(";", insufficientMsgs) : ""; + _logger.LogInformation("库存不足,拒绝发货: OrderId={OrderId}, 缺货明细={Detail}", orderId, detail); + result.ErrorMessage = string.IsNullOrEmpty(detail) + ? "商品库存不足,无法发货,请先到 ERP 补充库存后再试" + : $"商品库存不足,无法发货:{detail}"; + return result; } + + _logger.LogInformation("库存充足,使用销售出库单: OrderId={OrderId}", orderId); + } + catch (Exception ex) + { + _logger.LogError(ex, "库存校验异常,拒绝发货: OrderId={OrderId}", orderId); + result.ErrorMessage = $"库存校验异常:{ex.Message}"; + return result; } } @@ -1727,13 +2013,13 @@ public class OrderService var defaultsData = defaultsResult?["data"]; if (defaultsData != null) { - if (string.IsNullOrEmpty(warehouseId)) - warehouseId = defaultsData["mrck"]?.ToString() ?? ""; + // 出库仓库:不再使用全局默认仓库(该默认仅供 ERP 常规单据使用)。 + // 抖音发货单仅使用发货单 cjck 或抖音店铺设置 ck。 if (string.IsNullOrEmpty(defaultKh)) defaultKh = defaultsData["mrwldw"]?.ToString() ?? ""; if (string.IsNullOrEmpty(defaultSkzh)) defaultSkzh = defaultsData["mrskzh"]?.ToString() ?? ""; - _logger.LogInformation("获取默认选项设置: cjck={Cjck}, kh={Kh}, skzh={Skzh}", warehouseId, defaultKh, defaultSkzh); + _logger.LogInformation("获取默认往来单位/收款账户: kh={Kh}, skzh={Skzh}; 出库仓库={Cjck}", defaultKh, defaultSkzh, warehouseId); } } } @@ -1769,11 +2055,46 @@ public class OrderService payAmtFen = order.PayAmount ?? 0L; } + // 金额口径(与"商家实际收入"功能对齐): + // ydje(应收)= 订单应收金额(抖音 orderAmount,汇总合并单) + // 用户支付金额 = 抖音 payAmount(汇总合并单),不直接写入 ERP 某个字段 + // skje(收款金额)= 发货单上的"商家实际收入"(用户可改,默认=用户支付金额) + // ysje(优惠金额)= ydje − 用户支付金额(与原口径保持一致,不随商家实际收入联动) var discountFen = orderAmtFen - payAmtFen; if (discountFen < 0) discountFen = 0; var ydjeYuan = orderAmtFen / 100.0m; - var skjeYuan = payAmtFen / 100.0m; - var ysjeYuan = discountFen / 100.0m; + var payAmtYuan = payAmtFen / 100.0m; // 用户支付金额 + var ysjeYuan = discountFen / 100.0m; // 优惠 = ydje − 用户支付金额 + + // 优先级:参数传入 merchantIncome > 发货单已保存的 MerchantIncome > 默认=用户支付金额 + decimal skjeYuan; + if (merchantIncome.HasValue) + skjeYuan = merchantIncome.Value; + else if (waybill?.MerchantIncome != null) + skjeYuan = waybill.MerchantIncome.Value; + else + skjeYuan = payAmtYuan; + if (skjeYuan < 0) skjeYuan = 0; + _logger.LogInformation( + "销售出库单金额口径: OrderId={OrderId}, ydje(应收)={Ydje}, 用户支付={Pay}, skje(商家实收)={Skje}, ysje(优惠=应收-用户支付)={Ysje}", + orderId, ydjeYuan, payAmtYuan, skjeYuan, ysjeYuan); + + // 构建收款渠道明细 JSON(ERP 的"收款渠道明细"读的就是这个字段,skmx 空时 UI 会显示无数据) + // 单账户抖音单:[{"skfs":"入账账户","skje":"49.00","skzh":"<账户id>"}] + var skmxJson = ""; + if (!string.IsNullOrEmpty(defaultSkzh) && skjeYuan > 0) + { + var skmxArr = new JArray + { + new JObject + { + ["skfs"] = "入账账户", + ["skje"] = skjeYuan.ToString("0.00"), + ["skzh"] = defaultSkzh + } + }; + skmxJson = skmxArr.ToString(Newtonsoft.Json.Formatting.None); + } // 合并订单的抖音订单号(逗号分隔)和运单号 var finalDouyinOrderIds = !string.IsNullOrEmpty(douyinOrderIds) ? douyinOrderIds : (order.OrderId ?? ""); @@ -1791,6 +2112,8 @@ public class OrderService cjck = warehouseId, rkck = "", jsr = "", + // 发货人:参数传入优先(当前本次提交值),否则回退 waybill 已保存值 + fhr = !string.IsNullOrWhiteSpace(fhr) ? fhr.Trim() : (waybill?.Fhr ?? ""), yddh = finalTrackingNumber, dyddh = finalDouyinOrderIds, ydje = ydjeYuan, @@ -1806,7 +2129,7 @@ public class OrderService gys = "", djzt = "已审核", ly = "抖音订单", - skmx = "", + skmx = skmxJson, wtXsckdMxList = salesOrderDetails }; @@ -2153,6 +2476,38 @@ public class OrderService } /// + /// 删除 ERP 侧的销售/预售出库单(用于抖音端编辑发货单时,先清掉旧单再生成新单) + /// + /// ERP 销售出库单 ID + /// 是否删除成功;已不存在、已审核或关联退货单等情况会返回 false 并写入日志 + public async Task DeleteErpSalesOrderAsync(string salesOrderId) + { + if (string.IsNullOrWhiteSpace(salesOrderId)) return false; + try + { + var url = $"{_erpApiConfig.BaseUrl}/api/Extend/wtxsckd/{salesOrderId}"; + var request = await CreateAuthenticatedRequestAsync(HttpMethod.Delete, url); + var response = await _httpClient.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("已删除 ERP 销售出库单: SalesOrderId={SalesOrderId}, Resp={Body}", salesOrderId, body); + return true; + } + + _logger.LogWarning("删除 ERP 销售出库单失败: SalesOrderId={SalesOrderId}, Status={Status}, Body={Body}", + salesOrderId, response.StatusCode, body); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "删除 ERP 销售出库单异常: SalesOrderId={SalesOrderId}", salesOrderId); + return false; + } + } + + /// /// 创建带认证的HTTP请求 /// public async Task CreateAuthenticatedRequestAsync(HttpMethod method, string url, HttpContent? content = null) diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs new file mode 100644 index 0000000..1790007 --- /dev/null +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/ShopConfigProvider.cs @@ -0,0 +1,144 @@ +using DouyinLogistics.API.Models; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace DouyinLogistics.API.Services; + +/// +/// 从 ERP(WtDyDpsz/internal/all)拉取抖音店铺配置。 +/// 如果 ERP 不可用或未配置任何店铺,则回退到 appsettings.json 中的 DouyinShops。 +/// +public class ShopConfigProvider +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly ErpApiConfig _erpApiConfig; + private readonly List _fallbackConfigs; + + public ShopConfigProvider( + IHttpClientFactory httpClientFactory, + ILogger logger, + ErpApiConfig erpApiConfig, + IEnumerable fallbackConfigs) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _erpApiConfig = erpApiConfig; + _fallbackConfigs = fallbackConfigs?.ToList() ?? new List(); + } + + /// + /// 拉取最新店铺配置;ERP 拉不到时自动回退 appsettings。 + /// + public async Task> FetchAllAsync(CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_erpApiConfig?.BaseUrl)) + { + _logger.LogWarning("未配置 ErpApi.BaseUrl,使用 appsettings.json 中的 DouyinShops 作为兜底"); + return _fallbackConfigs; + } + + try + { + var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + if (!client.DefaultRequestHeaders.Contains("User-Agent")) + client.DefaultRequestHeaders.Add("User-Agent", "DouyinLogistics-API/1.0"); + var url = $"{_erpApiConfig.BaseUrl.TrimEnd('/')}/api/Extend/WtDyDpsz/internal/all"; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + var resp = await client.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("从 ERP 拉取店铺配置失败: {Status} {Body}", resp.StatusCode, body); + return _fallbackConfigs; + } + + var list = ParseShops(body); + if (list.Count == 0) + { + _logger.LogWarning("ERP 返回的店铺列表为空,回退到 appsettings"); + return _fallbackConfigs; + } + _logger.LogInformation("✅ 已从 ERP 拉取 {Count} 个抖音店铺配置", list.Count); + return list; + } + catch (Exception ex) + { + _logger.LogError(ex, "从 ERP 拉取店铺配置异常,回退到 appsettings"); + return _fallbackConfigs; + } + } + + private static List ParseShops(string json) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(json)) return result; + using var doc = JsonDocument.Parse(json); + // NCC 框架响应统一为 { code, data, msg, ... } + JsonElement array; + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + array = doc.RootElement; + } + else if (doc.RootElement.TryGetProperty("data", out var dataElem)) + { + if (dataElem.ValueKind == JsonValueKind.Array) + array = dataElem; + else + return result; + } + else + { + return result; + } + + foreach (var item in array.EnumerateArray()) + { + var cfg = new DouyinConfig + { + ShopId = GetLong(item, "shopId"), + ShopName = GetString(item, "shopName"), + AppKey = GetString(item, "appKey"), + AppSecret = GetString(item, "appSecret"), + CallbackUrl = GetString(item, "callbackUrl"), + ApiBaseUrl = EmptyFallback(GetString(item, "apiBaseUrl"), "https://openapi-fxg.jinritemai.com"), + SyncDays = (int)GetLong(item, "syncDays", 30), + SenderName = GetString(item, "senderName"), + SenderPhone = GetString(item, "senderPhone"), + SenderAddress = GetString(item, "senderAddress"), + SenderProvince = GetString(item, "senderProvince"), + SenderCity = GetString(item, "senderCity"), + SenderDistrict = GetString(item, "senderDistrict"), + SenderStreet = GetString(item, "senderStreet"), + }; + if (cfg.ShopId > 0 && !string.IsNullOrEmpty(cfg.AppKey) && !string.IsNullOrEmpty(cfg.AppSecret)) + { + result.Add(cfg); + } + } + return result; + } + + private static string GetString(JsonElement el, string name) + { + if (el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) + return prop.GetString() ?? string.Empty; + return string.Empty; + } + + private static long GetLong(JsonElement el, string name, long def = 0) + { + if (!el.TryGetProperty(name, out var prop)) return def; + return prop.ValueKind switch + { + JsonValueKind.Number => prop.TryGetInt64(out var v) ? v : def, + JsonValueKind.String => long.TryParse(prop.GetString(), out var v) ? v : def, + _ => def + }; + } + + private static string EmptyFallback(string s, string def) => + string.IsNullOrWhiteSpace(s) ? def : s; +} diff --git a/Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json b/Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json index ad97cef..50c51ec 100644 --- a/Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json +++ b/Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.json @@ -9,24 +9,8 @@ "ConnectionStrings": { "DefaultConnection": "Server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;Port=3306;Database=ncc_wutong;Uid=netteam;Pwd=netteam;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=true;" }, - "DouyinShops": [ - { - "ShopName": "梧桐官方店", - "SyncDays": 30, - "AppKey": "7463313337959335475", - "AppSecret": "648ddb67-b3ba-48a3-905c-ea761e69a87e", - "CallbackUrl": "http://localhost:5070/api/auth/callback", - "ApiBaseUrl": "https://openapi-fxg.jinritemai.com", - "ShopId": 37714425, - "SenderName": "PongGame", - "SenderPhone": "18014801756", - "SenderAddress": "浦洲路39号沿海创中心A区301室", - "SenderProvince": "江苏省", - "SenderCity": "南京市", - "SenderDistrict": "浦口区", - "SenderStreet": "沿江街道" - } - ], + "__DouyinShops_Readme": "抖音店铺配置已迁移至 ERP「抖音店铺设置」(wt_dy_dpsz),启动时从 ERP 拉取。下面的 DouyinShops 仅作为 ERP 不可用时的兜底,生产环境可留空。", + "DouyinShops": [], "Sf": { "CustomerCode": "your_customer_code", "CheckWord": "your_check_word", diff --git a/Antis.Erp.Plat/douyin/frontend/src/api/order.ts b/Antis.Erp.Plat/douyin/frontend/src/api/order.ts index b179812..b0bcc9a 100644 --- a/Antis.Erp.Plat/douyin/frontend/src/api/order.ts +++ b/Antis.Erp.Plat/douyin/frontend/src/api/order.ts @@ -36,6 +36,10 @@ export interface Order { hasSubmittedShipmentForm?: boolean /** 是否“已发货-未提交发货单” */ isPendingShipmentForm?: boolean + /** 是否已被用户手动从合并组拆分出来(true 表示不再参与自动合并) */ + noMerge?: boolean + /** 是否可以"恢复合并":noMerge=true 且同买家+同地址下仍有其他待发货订单 */ + canUnsplit?: boolean receiverName: string receiverPhone: string receiverAddress: string @@ -195,6 +199,16 @@ export const getMergedOrderDetail = (ids: number[]) => { return api.get('/orders/detail/merged', { params: { ids: ids.join(',') } }) } +// 拆分合并单:把这组订单从自动合并组中拆出来 +export const splitMergedOrders = (orderIds: number[]) => { + return api.post('/orders/split', { orderIds }) +} + +// 恢复合并:取消"拆分"标记 +export const unsplitOrders = (orderIds: number[]) => { + return api.post('/orders/unsplit', { orderIds }) +} + // 查询ERP商品列表(pl:单个品类 F_Id;ERP 商品可挂多品类逗号存储,后端按「包含该 ID」匹配) export const searchProducts = (keyword?: string, pageIndex = 1, pageSize = 20, pl?: string) => { const params: any = { pageIndex, pageSize } @@ -251,6 +265,11 @@ export const getPaymentAccounts = () => { return api.get('/orders/payment-accounts') } +// 获取 ERP 用户列表(用于"发货人"下拉) +export const getUsers = () => { + return api.get('/orders/users') +} + // 获取抖音店铺设置(往来单位/收款账户) export const getShopSettings = (shopId: number) => { return api.get(`/orders/shop-settings/${shopId}`) diff --git a/Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue b/Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue index 291cc66..7f9f078 100644 --- a/Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue +++ b/Antis.Erp.Plat/douyin/frontend/src/components/SerialNumberSelect.vue @@ -16,27 +16,22 @@
- - - - - - + + - + 查询 @@ -44,18 +39,24 @@ - - +
+ 需选择 {{ maxCount }} 个,已选 + {{ selectedSerialNumbers.length }} 个 +
+ + + - - - - + + + @@ -1492,11 +1696,33 @@ onMounted(() => { } /* ── 订单号复制 ── */ -.order-id-copy { +.order-id-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 28px; +} + +.order-id-row { display: flex; align-items: center; gap: 6px; - min-height: 28px; + line-height: 22px; +} + +.order-id-index { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + background: #ecf5ff; + color: #409eff; + border-radius: 9px; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; } .order-id-text { @@ -1507,12 +1733,47 @@ onMounted(() => { word-break: break-all; } +.order-id-all { + margin-top: 2px; +} + .copy-btn { flex-shrink: 0; font-size: 12px; padding: 0 4px; } +/* ── 物流单号回显 ── */ +.tracking-wrap { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; +} + +.tracking-company { + flex-shrink: 0; +} + +.tracking-no { + font-size: 13px; + font-weight: 600; + color: #303133; + letter-spacing: 0.2px; + user-select: text; + word-break: break-all; +} + +.tracking-shipped { + flex-shrink: 0; +} + +.tracking-time { + font-size: 12px; + color: #909399; +} + /* ── 金额 ── */ .amount-text { font-size: 13px; @@ -1574,6 +1835,30 @@ onMounted(() => { flex-shrink: 0; background: #f5f7fa; border: 1px dashed #dcdfe6; + display: flex; + align-items: center; + justify-content: center; + color: #c0c4cc; + font-size: 11px; + text-align: center; + line-height: 1.2; +} + +/* 商品清单单元格内的"暂无图片"占位 */ +.cell-no-image { + width: 50px; + height: 50px; + margin: 0 auto; + border-radius: 4px; + background: #f5f7fa; + border: 1px dashed #dcdfe6; + display: flex; + align-items: center; + justify-content: center; + color: #c0c4cc; + font-size: 11px; + text-align: center; + line-height: 1.2; } .product-info { @@ -1670,16 +1955,19 @@ onMounted(() => { /* ── 序列号 ── */ .serial-number-selector { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; min-height: 30px; } -.selected-serial-numbers { - display: flex; - flex-wrap: wrap; - gap: 2px; - margin-bottom: 4px; +.sn-count-tag { + cursor: pointer; + user-select: none; } + /* ── 滚动条 ── */ .order-product-list::-webkit-scrollbar { width: 5px; @@ -1705,3 +1993,29 @@ onMounted(() => { } + + + diff --git a/Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue b/Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue index fda5a66..5cd5380 100644 --- a/Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue +++ b/Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue @@ -171,7 +171,7 @@ @selection-change="handleSelectionChange" @sort-change="handleSortChange" > - + - +