Commit 1946f6a5e9dcc601eb077f8fa2a923fbdf839179

Authored by hexiaodong
1 parent 6c321426

feat(douyin): 退款中状态、发货单重提重建销售出库单;wtSp/NCC 相关调整

- 抖音同步:after_sale_info.refund_status=1 识别退款中;MapOrderStatus 20→本地 Status 4
- 订单合并、运单/发货、前端筛选与创建运单页支持退款中(4)
- 手动更新发货单时清除 SalesOrderId,使含 ERP 赠品的明细可重新生成销售出库单
- 附带 antis-ncc-admin wtSp、NCC Extend、launchSettings、前端 config 等既有改动

Made-with: Cursor
Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/Form.vue
... ... @@ -10,7 +10,7 @@
10 10 </el-col>
11 11 <el-col :span="24">
12 12 <el-form-item label="商品品类" prop="pl">
13   - <el-select v-model="dataForm.pl" placeholder="请选择" clearable :style='{"width":"100%"}' >
  13 + <el-select v-model="dataForm.pl" placeholder="请选择(可多选)" clearable multiple collapse-tags :style='{"width":"100%"}' >
14 14 <el-option v-for="(item, index) in plOptions" :key="index" :label="item.F_Plmc" :value="item.F_Id" ></el-option>
15 15 </el-select>
16 16 </el-form-item>
... ... @@ -161,7 +161,7 @@
161 161 dataForm: {
162 162 id:'',
163 163 spmc:undefined,
164   - pl:undefined,
  164 + pl:[],
165 165 pp:undefined,
166 166 spbm:undefined,
167 167 dyspid:undefined,
... ... @@ -313,6 +313,9 @@
313 313 this.dataForm = res.data;
314 314 if(!this.dataForm.spzt)this.dataForm.spzt=[];
315 315 if(!this.dataForm.xsmd)this.dataForm.xsmd=[];
  316 + // 商品品类:接口为逗号分隔的多个 F_Id
  317 + const plRaw = this.dataForm.pl;
  318 + this.dataForm.pl = plRaw ? String(plRaw).split(',').map(s => s.trim()).filter(Boolean) : [];
316 319 this.getStock(this.dataForm.spbm);
317 320 })
318 321 } else {
... ... @@ -386,7 +389,9 @@
386 389 tcfs_bl: this.dataForm.tcfs_bl || null,
387 390 spxlhType: this.dataForm.spxlhType || null,
388 391 xsqd: this.dataForm.xsqd || null,
389   - pl: this.dataForm.pl || null,
  392 + pl: Array.isArray(this.dataForm.pl) && this.dataForm.pl.length
  393 + ? this.dataForm.pl.join(',')
  394 + : (this.dataForm.pl && typeof this.dataForm.pl === 'string' ? this.dataForm.pl : null),
390 395 pp: this.dataForm.pp || null,
391 396 spbm: this.dataForm.spbm || null,
392 397 dyspid: this.dataForm.dyspid || null,
... ...
Antis.Erp.Plat/antis-ncc-admin/src/views/wtSp/index.vue
1   -<template>
  1 +<template>
2 2 <div class="NCC-common-layout">
3 3 <div class="NCC-common-layout-center">
4 4 <el-row class="NCC-common-search-box" :gutter="16">
... ... @@ -101,8 +101,8 @@
101 101 </div>
102 102 <NCC-table v-loading="listLoading" :data="list" has-c @selection-change="handleSelectionChange">
103 103 <el-table-column prop="spmc" label="商品名称" align="left" />
104   - <el-table-column label="商品品类" prop="pl" align="left">
105   - <template slot-scope="scope">{{ scope.row.pl | dynamicText(plOptions) }}</template>
  104 + <el-table-column label="商品品类" prop="pl" align="left" min-width="140" show-overflow-tooltip>
  105 + <template slot-scope="scope">{{ scope.row.pl }}</template>
106 106 </el-table-column>
107 107 <el-table-column label="商品品牌" prop="pp" align="left">
108 108 <template slot-scope="scope">{{ scope.row.pp | dynamicText(ppOptions) }}</template>
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/OrdersController.cs
... ... @@ -5,6 +5,7 @@ using SqlSugar;
5 5 using Newtonsoft.Json;
6 6 using Newtonsoft.Json.Linq;
7 7 using System.Net.Http;
  8 +using System.Net.Sockets;
8 9 using System.Linq;
9 10  
10 11 namespace DouyinLogistics.API.Controllers;
... ... @@ -40,6 +41,8 @@ public class OrdersController : ControllerBase
40 41 [FromQuery] string? trackingNumber = null,
41 42 [FromQuery] string? productName = null,
42 43 [FromQuery] bool? hasWaybill = null,
  44 + /// <summary>为 true 时:待发货 + 已有运单号 + 尚未成功提交发货单(waybills 无 SalesOrderId)</summary>
  45 + [FromQuery] bool pendingShipmentForm = false,
43 46 [FromQuery] DateTime? createTimeStart = null,
44 47 [FromQuery] DateTime? createTimeEnd = null,
45 48 [FromQuery] DateTime? payTimeStart = null,
... ... @@ -52,7 +55,7 @@ public class OrdersController : ControllerBase
52 55  
53 56 var (orders, total) = await _orderService.GetOrdersWithPagingAsync(
54 57 pageIndex, pageSize, status, orderId, receiverName, receiverPhone,
55   - trackingNumber, productName, hasWaybill, createTimeStart, createTimeEnd, payTimeStart, payTimeEnd);
  58 + trackingNumber, productName, hasWaybill, pendingShipmentForm, createTimeStart, createTimeEnd, payTimeStart, payTimeEnd);
56 59 return Ok(new { data = orders, total, pageIndex, pageSize });
57 60 }
58 61 catch (Exception ex)
... ... @@ -476,6 +479,11 @@ public class OrdersController : ControllerBase
476 479 return BadRequest(new { message = "订单已存在运单号" });
477 480 }
478 481  
  482 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  483 + {
  484 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许创建运单" });
  485 + }
  486 +
479 487 // 调试日志
480 488 Console.WriteLine($"🔍 [控制器] 创建运单请求: orderId={id}, 使用订单中的手机号: {order.ReceiverPhone}");
481 489 _logger.LogInformation($"创建运单请求: orderId={id}, 使用订单中的手机号: {order.ReceiverPhone}");
... ... @@ -619,7 +627,7 @@ public class OrdersController : ControllerBase
619 627 [FromQuery] string? spmc = null, // 商品名称
620 628 [FromQuery] string? spbm = null, // 商品编码
621 629 [FromQuery] string? dyspid = null, // 抖音SKU编码
622   - [FromQuery] string? pl = null, // 商品品类(wt_pl.F_Id
  630 + [FromQuery] string? pl = null, // 商品品类(单个 wt_pl.F_Id;商品可多品类逗号存储,ERP 按包含匹配
623 631 [FromQuery] int pageIndex = 1,
624 632 [FromQuery] int pageSize = 20)
625 633 {
... ... @@ -789,7 +797,8 @@ public class OrdersController : ControllerBase
789 797 catch (Exception ex)
790 798 {
791 799 _logger.LogError(ex, "查询ERP商品失败");
792   - return StatusCode(500, new { message = "查询ERP商品失败", error = ex.Message });
  800 + var hint = GetErpConnectionHint(ex);
  801 + return StatusCode(500, new { message = "查询ERP商品失败", error = hint ?? ex.Message });
793 802 }
794 803 }
795 804  
... ... @@ -861,7 +870,8 @@ public class OrdersController : ControllerBase
861 870 var data = result["data"];
862 871 if (data == null || data.Type == Newtonsoft.Json.Linq.JTokenType.Null)
863 872 {
864   - return NotFound(new { message = "未找到商品信息" });
  873 + // 使用 200 + data=null,避免前端 axios 将 HTTP 404 当作异常
  874 + return Ok(new { code = 200, data = (object?)null, message = "未找到商品信息" });
865 875 }
866 876  
867 877 // 解析商品列表
... ... @@ -877,7 +887,7 @@ public class OrdersController : ControllerBase
877 887  
878 888 if (list == null || (list is Newtonsoft.Json.Linq.JArray productArray && productArray.Count == 0))
879 889 {
880   - return NotFound(new { message = "未找到商品信息" });
  890 + return Ok(new { code = 200, data = (object?)null, message = "未找到商品信息" });
881 891 }
882 892  
883 893 // 获取第一个商品(或匹配的商品)
... ... @@ -892,6 +902,16 @@ public class OrdersController : ControllerBase
892 902 .FirstOrDefault(p => p["spbm"]?.ToString() == productCode);
893 903 }
894 904  
  905 + // 仅传 skuId 时:优先 dyspid 完全匹配(keyword 可能命中多条或误命中名称)
  906 + if (productObj == null && !string.IsNullOrEmpty(skuId))
  907 + {
  908 + productObj = productArray2
  909 + .OfType<Newtonsoft.Json.Linq.JObject>()
  910 + .FirstOrDefault(p =>
  911 + string.Equals(p["dyspid"]?.ToString(), skuId, StringComparison.Ordinal)
  912 + || string.Equals(p["Dyspid"]?.ToString(), skuId, StringComparison.Ordinal));
  913 + }
  914 +
895 915 // 如果没找到完全匹配的,使用第一个
896 916 if (productObj == null && productArray2.Count > 0)
897 917 {
... ... @@ -901,6 +921,28 @@ public class OrdersController : ControllerBase
901 921  
902 922 if (productObj != null)
903 923 {
  924 + string? infoImageUrl = null;
  925 + var infoSpztRaw = productObj["spzt"]?.ToString();
  926 + if (!string.IsNullOrEmpty(infoSpztRaw))
  927 + {
  928 + try
  929 + {
  930 + var spztArr = JsonConvert.DeserializeObject<JArray>(infoSpztRaw);
  931 + if (spztArr != null && spztArr.Count > 0)
  932 + {
  933 + var firstImg = spztArr[0] as JObject;
  934 + var relUrl = firstImg?["url"]?.ToString();
  935 + if (!string.IsNullOrEmpty(relUrl))
  936 + {
  937 + infoImageUrl = relUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)
  938 + ? relUrl
  939 + : erpApiConfig.BaseUrl.TrimEnd('/') + relUrl;
  940 + }
  941 + }
  942 + }
  943 + catch { /* ignore */ }
  944 + }
  945 +
904 946 return Ok(new
905 947 {
906 948 code = 200,
... ... @@ -909,17 +951,18 @@ public class OrdersController : ControllerBase
909 951 id = productObj["id"]?.ToString(),
910 952 spbm = productObj["spbm"]?.ToString(),
911 953 spmc = productObj["spmc"]?.ToString(),
912   - spxlhType = productObj["spxlhType"]?.ToString(), // 序列号类型
  954 + spxlhType = productObj["spxlhType"]?.ToString() ?? productObj["SpxlhType"]?.ToString(),
913 955 lsj = productObj["lsj"]?.ToObject<decimal>() ?? 0,
914 956 dw = productObj["dw"]?.ToString(),
915 957 gg = productObj["gg"]?.ToString(),
916   - dyspid = productObj["dyspid"]?.ToString()
  958 + dyspid = productObj["dyspid"]?.ToString(),
  959 + imageUrl = infoImageUrl
917 960 }
918 961 });
919 962 }
920 963 }
921 964  
922   - return NotFound(new { message = "未找到商品信息" });
  965 + return Ok(new { code = 200, data = (object?)null, message = "未找到商品信息" });
923 966 }
924 967 catch (Exception ex)
925 968 {
... ... @@ -1342,7 +1385,8 @@ public class OrdersController : ControllerBase
1342 1385 catch (Exception ex)
1343 1386 {
1344 1387 _logger.LogError(ex, "查询仓库列表失败");
1345   - return StatusCode(500, new { message = "查询仓库列表失败", error = ex.Message });
  1388 + var hint = GetErpConnectionHint(ex);
  1389 + return StatusCode(500, new { message = "查询仓库列表失败", error = hint ?? ex.Message });
1346 1390 }
1347 1391 }
1348 1392  
... ... @@ -1402,6 +1446,11 @@ public class OrdersController : ControllerBase
1402 1446 return BadRequest(new { message = "订单尚未创建运单,请先创建运单" });
1403 1447 }
1404 1448  
  1449 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  1450 + {
  1451 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许发货" });
  1452 + }
  1453 +
1405 1454 var result = await _orderService.ShipToDouyinAsync(id);
1406 1455 if (result.Success)
1407 1456 {
... ... @@ -1510,6 +1559,18 @@ public class OrdersController : ControllerBase
1510 1559 }
1511 1560 }
1512 1561  
  1562 + private static string? GetErpConnectionHint(Exception ex)
  1563 + {
  1564 + if (ex is HttpRequestException or SocketException or TaskCanceledException)
  1565 + return "无法连接 ERP:请确认 NCC.API 已启动,且 appsettings 中 ErpApi.BaseUrl 与 NCC 实际监听地址一致。";
  1566 + for (var e = ex.InnerException; e != null; e = e.InnerException)
  1567 + {
  1568 + if (e is HttpRequestException or SocketException)
  1569 + return "无法连接 ERP:请确认 NCC.API 已启动,且 appsettings 中 ErpApi.BaseUrl 与 NCC 实际监听地址一致。";
  1570 + }
  1571 + return null;
  1572 + }
  1573 +
1513 1574 /// <summary>
1514 1575 /// 清理所有订单并重新同步(天数由 appsettings.json 的 Douyin.SyncDays 配置,默认30天)
1515 1576 /// </summary>
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Controllers/WaybillController.cs
... ... @@ -32,9 +32,9 @@ public class WaybillController : ControllerBase
32 32 {
33 33 return NotFound(new { message = "订单不存在" });
34 34 }
35   - if (order.Status == 2 || order.Status == 3)
  35 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
36 36 {
37   - return BadRequest(new { message = "已取消或已退款的订单不允许进行任何操作" });
  37 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" });
38 38 }
39 39  
40 40 var preview = await _orderService.GetWaybillPreviewAsync(orderId);
... ... @@ -67,9 +67,9 @@ public class WaybillController : ControllerBase
67 67 {
68 68 return NotFound(new { message = "订单不存在" });
69 69 }
70   - if (order.Status == 2 || order.Status == 3)
  70 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
71 71 {
72   - return BadRequest(new { message = "已取消或已退款的订单不允许进行任何操作" });
  72 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" });
73 73 }
74 74  
75 75 var result = await _orderService.PrintWaybillAsync(orderId);
... ... @@ -110,9 +110,9 @@ public class WaybillController : ControllerBase
110 110 {
111 111 return NotFound(new { message = "订单不存在" });
112 112 }
113   - if (order.Status == 2 || order.Status == 3)
  113 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
114 114 {
115   - return BadRequest(new { message = "已取消或已退款的订单不允许进行任何操作" });
  115 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" });
116 116 }
117 117  
118 118 var result = await _orderService.PrintWaybillAndShipAsync(orderId);
... ... @@ -174,10 +174,10 @@ public class WaybillController : ControllerBase
174 174 return NotFound(new { message = "订单不存在" });
175 175 }
176 176  
177   - // 检查订单状态,已取消/已退款的订单不允许创建发货单
178   - if (order.Status == 2 || order.Status == 3)
  177 + // 检查订单状态,已取消/已退款/退款中的订单不允许创建发货单
  178 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
179 179 {
180   - return BadRequest(new { message = "已取消或已退款的订单不允许进行任何操作" });
  180 + return BadRequest(new { message = "已取消、已退款或退款中的订单不允许进行任何操作" });
181 181 }
182 182  
183 183 var db = HttpContext.RequestServices.GetRequiredService<ISqlSugarClient>();
... ... @@ -261,6 +261,14 @@ public class WaybillController : ControllerBase
261 261 existingWaybill.Remark = request.Remark ?? existingWaybill.Remark;
262 262 existingWaybill.UpdateTime = DateTime.Now;
263 263  
  264 + // 商品明细变更时清除已关联的销售出库单,让后续流程重新创建
  265 + if (!string.IsNullOrEmpty(existingWaybill.SalesOrderId))
  266 + {
  267 + _logger.LogInformation("发货单商品明细已更新,清除旧销售出库单关联: WaybillId={WaybillId}, OldSalesOrderId={OldSalesOrderId}",
  268 + existingWaybill.Id, existingWaybill.SalesOrderId);
  269 + existingWaybill.SalesOrderId = null;
  270 + }
  271 +
264 272 await db.Updateable(existingWaybill).ExecuteCommandAsync();
265 273 waybill = existingWaybill;
266 274 waybillId = existingWaybill.Id;
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Models/Order.cs
... ... @@ -18,7 +18,7 @@ public class Order
18 18 public string OrderId { get; set; } = string.Empty;
19 19  
20 20 /// <summary>
21   - /// 订单状态:0-待发货,1-已发货,2-已取消,3-已退款
  21 + /// 订单状态:0-待发货,1-已发货,2-已取消,3-已退款,4-退款中
22 22 /// </summary>
23 23 public int Status { get; set; }
24 24  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/DouyinService.cs
1 1 using DouyinLogistics.API.Models;
2 2 using Newtonsoft.Json;
  3 +using Newtonsoft.Json.Linq;
3 4 using System.Security.Cryptography;
4 5 using System.Text;
5 6  
... ... @@ -24,6 +25,107 @@ public class DouyinService
24 25 }
25 26  
26 27 /// <summary>
  28 + /// 合并抖音主单/子单状态与文案,识别退款完结(21/22/39)与退款流程中。
  29 + /// 抖店在「退款中」时仍可能返回 order_status=2(待发货),仅靠 order_status 会误判。
  30 + /// 返回 20 表示退款处理中(内部约定,非抖音原始枚举),由 MapOrderStatus 映射为本地 4。
  31 + /// </summary>
  32 + private static int ResolveRefundAwareOrderStatus(JObject orderObj, int rawOrderStatus)
  33 + {
  34 + static bool IsRefundFinished(int s) => s is 21 or 22 or 39;
  35 +
  36 + var orderStatus = rawOrderStatus;
  37 + var mainStatus = orderObj["main_status"]?.ToObject<int?>();
  38 + var orderStatusDesc = orderObj["order_status_desc"]?.ToString() ?? "";
  39 + var mainStatusDesc = orderObj["main_status_desc"]?.ToString() ?? "";
  40 + var skuList = orderObj["sku_order_list"] as JArray;
  41 +
  42 + if (mainStatus.HasValue && IsRefundFinished(mainStatus.Value))
  43 + return mainStatus.Value;
  44 +
  45 + if (skuList != null)
  46 + {
  47 + foreach (var sku in skuList)
  48 + {
  49 + if (sku is not JObject so) continue;
  50 + var skuSt = so["order_status"]?.ToObject<int?>();
  51 + if (skuSt.HasValue && IsRefundFinished(skuSt.Value))
  52 + return skuSt.Value;
  53 + // 退款进行中时,主单/子单仍常为 order_status=2、文案「待发货」;真实状态在 after_sale_info(order.orderDetail 实测 refund_status=1)
  54 + var asi = so["after_sale_info"] as JObject;
  55 + if (asi != null)
  56 + {
  57 + var refundSt = asi["refund_status"]?.ToObject<int?>();
  58 + if (refundSt == 1)
  59 + return 20;
  60 + }
  61 + }
  62 + }
  63 +
  64 + static bool DescExcludesRefund(string a, string b)
  65 + {
  66 + var c = (a ?? "") + (b ?? "");
  67 + return c.Contains("不支持退款") || c.Contains("不可退款");
  68 + }
  69 +
  70 + static bool DescRefundInProgress(string a, string b)
  71 + {
  72 + if (DescExcludesRefund(a, b)) return false;
  73 + var c = (a ?? "") + (b ?? "");
  74 + return c.Contains("退款中") || c.Contains("退款申请") || c.Contains("售后中")
  75 + || c.Contains("售后处理") || c.Contains("仅退款") || c.Contains("退货退款");
  76 + }
  77 +
  78 + static bool DescRefundDone(string a, string b)
  79 + {
  80 + if (DescExcludesRefund(a, b)) return false;
  81 + var c = (a ?? "") + (b ?? "");
  82 + return c.Contains("退款完结") || c.Contains("已退款") || c.Contains("退款成功");
  83 + }
  84 +
  85 + // order_status 仍为待发货(2)或已发货(3)时,用主单/子单文案识别退款
  86 + if (orderStatus is 2 or 3)
  87 + {
  88 + if (DescRefundDone(orderStatusDesc, mainStatusDesc))
  89 + return 21;
  90 + if (DescRefundInProgress(orderStatusDesc, mainStatusDesc))
  91 + return 20;
  92 + if (skuList != null)
  93 + {
  94 + foreach (var sku in skuList)
  95 + {
  96 + if (sku is not JObject so) continue;
  97 + var sd = so["order_status_desc"]?.ToString() ?? "";
  98 + var smd = so["main_status_desc"]?.ToString() ?? "";
  99 + if (DescRefundDone(sd, smd)) return 21;
  100 + if (DescRefundInProgress(sd, smd)) return 20;
  101 + }
  102 + }
  103 + }
  104 +
  105 + // 原逻辑:已关闭(4)时从 main/sku/文案推断退款完结
  106 + if (orderStatus == 4)
  107 + {
  108 + if (mainStatus.HasValue && IsRefundFinished(mainStatus.Value))
  109 + return mainStatus.Value;
  110 + if (!DescExcludesRefund(orderStatusDesc, mainStatusDesc)
  111 + && (orderStatusDesc.Contains("退款") || mainStatusDesc.Contains("退款")))
  112 + return 21;
  113 + if (skuList != null)
  114 + {
  115 + foreach (var sku in skuList)
  116 + {
  117 + if (sku is not JObject so) continue;
  118 + var skuSt = so["order_status"]?.ToObject<int?>();
  119 + if (skuSt.HasValue && IsRefundFinished(skuSt.Value))
  120 + return skuSt.Value;
  121 + }
  122 + }
  123 + }
  124 +
  125 + return orderStatus;
  126 + }
  127 +
  128 + /// <summary>
27 129 /// 设置 Access Token(从授权回调获取)
28 130 /// </summary>
29 131 public void SetAccessToken(string accessToken)
... ... @@ -215,44 +317,15 @@ public class DouyinService
215 317 {
216 318 var orderId = orderObj["order_id"]?.ToString() ?? "";
217 319  
218   - // 从 ShopOrderListItem 提取订单信息,优先识别已退款状态(21/22/39)
219   - // 抖店可能将 order_status 返回为 4(已取消),但 main_status 或 sku 中为 21/22/39
220   - var orderStatus = orderObj["order_status"]?.ToObject<int>() ?? 0;
221   - var mainStatus = orderObj["main_status"]?.ToObject<int?>();
222   - var orderStatusDesc = orderObj["order_status_desc"]?.ToString() ?? "";
223   - var mainStatusDesc = orderObj["main_status_desc"]?.ToString() ?? "";
224   -
225   - // 若 order_status 为 4(已取消),检查 main_status 和 sku_order_list 是否为退款完结
226   - if (orderStatus == 4)
227   - {
228   - var origStatus = orderStatus;
229   - if (mainStatus.HasValue && (mainStatus.Value == 21 || mainStatus.Value == 22 || mainStatus.Value == 39))
230   - orderStatus = mainStatus.Value;
231   - else if (orderStatusDesc.Contains("退款") || mainStatusDesc.Contains("退款"))
232   - orderStatus = 21; // 描述含退款则视为发货前退款完结
233   - else
234   - {
235   - var skuList = orderObj["sku_order_list"] as Newtonsoft.Json.Linq.JArray;
236   - if (skuList != null)
237   - foreach (var sku in skuList)
238   - if (sku is Newtonsoft.Json.Linq.JObject so)
239   - {
240   - var skuStatus = so["order_status"]?.ToObject<int?>();
241   - if (skuStatus.HasValue && (skuStatus.Value == 21 || skuStatus.Value == 22 || skuStatus.Value == 39))
242   - {
243   - orderStatus = skuStatus.Value;
244   - break;
245   - }
246   - }
247   - }
248   - if (orderStatus != origStatus)
249   - _logger.LogInformation("订单 {OrderId} 状态修正: order_status={Orig}, main_status={Main}, 修正后={Final}", orderId, origStatus, mainStatus, orderStatus);
250   - }
  320 + var rawOrderStatus = orderObj["order_status"]?.ToObject<int>() ?? 0;
  321 + var orderStatus = ResolveRefundAwareOrderStatus(orderObj, rawOrderStatus);
  322 + if (orderStatus != rawOrderStatus)
  323 + _logger.LogInformation("订单 {OrderId} 状态修正: 原始 order_status={Raw}, 修正后={Final}", orderId, rawOrderStatus, orderStatus);
251 324  
252 325 var order = new DouyinOrder
253 326 {
254 327 OrderId = orderId,
255   - OrderStatus = orderStatus, // 保存抖音的原始状态(含修正后的退款状态)
  328 + OrderStatus = orderStatus, // 含 21/22/39 完结码与内部 20=退款处理中
256 329 OpenId = orderObj["open_id"]?.ToString() ?? orderObj["doudian_open_id"]?.ToString()
257 330 };
258 331  
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/Services/OrderService.cs
... ... @@ -58,8 +58,8 @@ public class OrderService
58 58  
59 59 /// <summary>
60 60 /// 将抖音订单状态映射到本地订单状态
61   - /// 抖音:2=待发货,3=已发货,21=发货前退款完结,22=发货后退款完结,39=收货后退款完结,4等=已取消
62   - /// 本地:0=待发货,1=已发货,2=已取消,3=已退款
  61 + /// 抖音:2=待发货,3=已发货,21/22/39=退款完结,4=已关闭;20=同步层识别的「退款处理中」(见 DouyinService)
  62 + /// 本地:0=待发货,1=已发货,2=已取消,3=已退款,4=退款中
63 63 /// </summary>
64 64 private int MapOrderStatus(int douyinStatus)
65 65 {
... ... @@ -67,6 +67,7 @@ public class OrderService
67 67 {
68 68 2 => 0, // 待发货(备货中)
69 69 3 => 1, // 已发货
  70 + 20 => 4, // 退款处理中(抖音文案/main_status 识别,非官方数字码)
70 71 21 => 3, // 发货前退款完结
71 72 22 => 3, // 发货后退款完结
72 73 39 => 3, // 收货后退款完结
... ... @@ -357,6 +358,13 @@ public class OrderService
357 358 {
358 359 try
359 360 {
  361 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  362 + {
  363 + _logger.LogWarning("订单状态不允许创建运单: DbId={DbId}, OrderId={OrderId}, Status={Status}",
  364 + order.Id, order.OrderId, order.Status);
  365 + return false;
  366 + }
  367 +
360 368 _logger.LogInformation("开始为订单创建运单: OrderId={OrderId}, ReceiverName={ReceiverName}",
361 369 order.OrderId, order.ReceiverName);
362 370  
... ... @@ -449,6 +457,7 @@ public class OrderService
449 457 string? trackingNumber = null,
450 458 string? productName = null,
451 459 bool? hasWaybill = null,
  460 + bool pendingShipmentForm = false,
452 461 DateTime? createTimeStart = null,
453 462 DateTime? createTimeEnd = null,
454 463 DateTime? payTimeStart = null,
... ... @@ -486,24 +495,7 @@ public class OrderService
486 495 query = query.Where(o => o.ProductName != null && o.ProductName.Contains(productName));
487 496 }
488 497  
489   - // 筛选是否有发货单
490   - if (hasWaybill.HasValue)
491   - {
492   - if (hasWaybill.Value)
493   - {
494   - // 有发货单:存在waybills表中有该订单的记录
495   - query = query.Where(o => SqlFunc.Subqueryable<Waybill>()
496   - .Where(w => w.OrderId == o.Id)
497   - .Any());
498   - }
499   - else
500   - {
501   - // 无发货单:waybills表中没有该订单的记录
502   - query = query.Where(o => !SqlFunc.Subqueryable<Waybill>()
503   - .Where(w => w.OrderId == o.Id)
504   - .Any());
505   - }
506   - }
  498 + // hasWaybill 在「同人同址合并」之后处理:否则只有子单有 waybills/运单号时,SQL 会整组拆散,列表里筛不出已建运单的合并单
507 499  
508 500 if (createTimeStart.HasValue)
509 501 {
... ... @@ -525,12 +517,14 @@ public class OrderService
525 517 query = query.Where(o => o.PayTime != null && o.PayTime <= payTimeEnd.Value.AddDays(1).AddSeconds(-1));
526 518 }
527 519  
528   - // 所有状态都参与合并(按买家+地址分组),已退款/已取消从合并组中剔除
  520 + // 所有状态都参与合并(按买家+地址分组),已退款/退款中/已取消从合并组中剔除
529 521 var allOrders = await query
530 522 .OrderBy($"IFNULL(PayTime, '1970-01-01 00:00:00') ASC, CreateTime ASC")
531 523 .Take(1000)
532 524 .ToListAsync();
533 525 var merged = MergeOrdersByBuyerAndAddress(allOrders);
  526 + if (pendingShipmentForm || hasWaybill.HasValue)
  527 + merged = await FilterMergedOrdersPostProcessAsync(merged, hasWaybill, pendingShipmentForm);
534 528 var total = merged.Count;
535 529 var paged = merged
536 530 .Skip((pageIndex - 1) * pageSize)
... ... @@ -543,7 +537,7 @@ public class OrderService
543 537 /// 合并订单规则:
544 538 /// 1. 待发货(0):同买家同地址 → 合并(用于一起发货)
545 539 /// 2. 已发货(1):同运单号 → 合并(之前合并发货的继续合并展示,分开发货的分开展示)
546   - /// 3. 已退款(3)中的订单从待发货合并组中剔除,单独显示
  540 + /// 3. 已退款(3)、退款中(4)从待发货合并组中剔除,单独显示
547 541 /// 4. 已取消(2):始终单独显示,不合并
548 542 /// </summary>
549 543 private List<Order> MergeOrdersByBuyerAndAddress(List<Order> orders)
... ... @@ -557,12 +551,14 @@ public class OrderService
557 551 var shippedOrders = orders.Where(o => o.Status == 1).ToList(); // 已发货
558 552 var cancelledOrders = orders.Where(o => o.Status == 2).ToList(); // 已取消
559 553 var refundedOrders = orders.Where(o => o.Status == 3).ToList(); // 已退款
  554 + var refundingOrders = orders.Where(o => o.Status == 4).ToList(); // 退款中
560 555  
561 556 // 规则4:已取消始终单独显示
562 557 result.AddRange(cancelledOrders);
563 558  
564   - // 规则3:已退款始终单独显示
  559 + // 规则3:已退款、退款中始终单独显示
565 560 result.AddRange(refundedOrders);
  561 + result.AddRange(refundingOrders);
566 562  
567 563 // 规则1:待发货按同买家同地址合并
568 564 var pendingGroups = pendingOrders
... ... @@ -645,10 +641,79 @@ public class OrderService
645 641 first.MergedOrderStatuses = list.Select(o => o.Status).ToList();
646 642 first.PayAmount = list.Sum(o => o.PayAmount ?? 0);
647 643 first.OrderAmount = list.Sum(o => o.OrderAmount ?? 0);
  644 + var withTracking = list.FirstOrDefault(o => !string.IsNullOrWhiteSpace(o.TrackingNumber));
  645 + if (withTracking != null)
  646 + {
  647 + first.TrackingNumber = withTracking.TrackingNumber;
  648 + if (!string.IsNullOrWhiteSpace(withTracking.LogisticsCompany))
  649 + first.LogisticsCompany = withTracking.LogisticsCompany;
  650 + }
648 651 return first;
649 652 }
650 653  
651 654 /// <summary>
  655 + /// 合并结果后筛选:hasWaybill 表示是否有运单/运单号;pendingShipmentForm 表示「有运单但未在系统中提交发货单」
  656 + /// (提交成功后会写入 waybills.SalesOrderId,即已生成 ERP 销售出库单)
  657 + /// </summary>
  658 + private async Task<List<Order>> FilterMergedOrdersPostProcessAsync(
  659 + List<Order> merged,
  660 + bool? hasWaybill,
  661 + bool pendingShipmentForm)
  662 + {
  663 + if (merged == null || merged.Count == 0)
  664 + return merged ?? new List<Order>();
  665 +
  666 + var allIds = merged
  667 + .SelectMany(m => m.MergedOrderIds != null && m.MergedOrderIds.Count > 0
  668 + ? m.MergedOrderIds
  669 + : new List<int> { m.Id })
  670 + .Distinct()
  671 + .ToList();
  672 +
  673 + var wbRows = await _db.Queryable<Waybill>()
  674 + .Where(w => allIds.Contains(w.OrderId))
  675 + .Select(w => new { w.OrderId, w.SalesOrderId })
  676 + .ToListAsync();
  677 + var hasWaybillRow = wbRows.Select(w => w.OrderId).ToHashSet();
  678 + var submittedOrderIds = wbRows
  679 + .Where(w => !string.IsNullOrWhiteSpace(w.SalesOrderId))
  680 + .Select(w => w.OrderId)
  681 + .ToHashSet();
  682 +
  683 + var trackRows = await _db.Queryable<Order>()
  684 + .Where(o => allIds.Contains(o.Id))
  685 + .Select(o => new { o.Id, o.TrackingNumber })
  686 + .ToListAsync();
  687 + var hasTracking = trackRows
  688 + .Where(t => !string.IsNullOrWhiteSpace(t.TrackingNumber))
  689 + .Select(t => t.Id)
  690 + .ToHashSet();
  691 +
  692 + bool GroupHasWaybill(IEnumerable<int> ids) =>
  693 + ids.Any(id => hasWaybillRow.Contains(id) || hasTracking.Contains(id));
  694 +
  695 + bool GroupHasSubmittedShipmentForm(IEnumerable<int> ids) =>
  696 + ids.Any(id => submittedOrderIds.Contains(id));
  697 +
  698 + return merged.Where(m =>
  699 + {
  700 + var ids = m.MergedOrderIds != null && m.MergedOrderIds.Count > 0
  701 + ? m.MergedOrderIds
  702 + : new List<int> { m.Id };
  703 + if (pendingShipmentForm)
  704 + {
  705 + return GroupHasWaybill(ids) && !GroupHasSubmittedShipmentForm(ids);
  706 + }
  707 + if (hasWaybill.HasValue)
  708 + {
  709 + var has = GroupHasWaybill(ids);
  710 + return hasWaybill.Value ? has : !has;
  711 + }
  712 + return true;
  713 + }).ToList();
  714 + }
  715 +
  716 + /// <summary>
652 717 /// 诊断订单合并情况(用于排查为何未合并)
653 718 /// </summary>
654 719 public async Task<List<object>> GetMergeDiagnosisAsync(params string[] orderIds)
... ... @@ -778,6 +843,13 @@ public class OrderService
778 843 return result;
779 844 }
780 845  
  846 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  847 + {
  848 + result.ErrorMessage = "已取消、已退款或退款中的订单不允许打印";
  849 + result.Success = false;
  850 + return result;
  851 + }
  852 +
781 853 // 标记为已打印(实际打印由前端完成)
782 854 result.Printed = true;
783 855 result.Shipped = false; // 不执行发货操作
... ... @@ -835,6 +907,12 @@ public class OrderService
835 907 return result;
836 908 }
837 909  
  910 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  911 + {
  912 + result.ErrorMessage = "已取消、已退款或退款中的订单不允许发货";
  913 + return result;
  914 + }
  915 +
838 916 // 如果订单状态还是待发货,则发货到抖音
839 917 if (order.Status == 0) // 待发货
840 918 {
... ... @@ -915,6 +993,12 @@ public class OrderService
915 993 return result;
916 994 }
917 995  
  996 + if (order.Status == 2 || order.Status == 3 || order.Status == 4)
  997 + {
  998 + result.ErrorMessage = "已取消、已退款或退款中的订单不允许打印并发货";
  999 + return result;
  1000 + }
  1001 +
918 1002 // 标记为已打印(这里只是标记,实际打印由前端完成)
919 1003 result.Printed = true;
920 1004 result.TrackingNumber = order.TrackingNumber;
... ...
Antis.Erp.Plat/douyin/DouyinLogistics.API/appsettings.Development.json
... ... @@ -4,5 +4,8 @@
4 4 "Default": "Information",
5 5 "Microsoft.AspNetCore": "Warning"
6 6 }
  7 + },
  8 + "ErpApi": {
  9 + "BaseUrl": "http://localhost:2011"
7 10 }
8 11 }
... ...
Antis.Erp.Plat/douyin/frontend/src/api/order.ts
... ... @@ -15,6 +15,7 @@ const api = axios.create({
15 15 export interface Order {
16 16 id: number
17 17 orderId: string
  18 + /** 0 待发货 1 已发货 2 已取消 3 已退款 4 退款中 */
18 19 status: number
19 20 /** 合并的订单ID列表(同人同地址合并时) */
20 21 mergedOrderIds?: number[]
... ... @@ -65,6 +66,8 @@ export const getOrders = (
65 66 trackingNumber?: string
66 67 productName?: string
67 68 hasWaybill?: boolean
  69 + /** 有运单但未提交发货单(未生成 ERP 出库单,后端用 waybills.SalesOrderId 判断) */
  70 + pendingShipmentForm?: boolean
68 71 createTimeStart?: string
69 72 createTimeEnd?: string
70 73 payTimeStart?: string
... ... @@ -80,6 +83,7 @@ export const getOrders = (
80 83 if (filters.trackingNumber) params.trackingNumber = filters.trackingNumber
81 84 if (filters.productName) params.productName = filters.productName
82 85 if (filters.hasWaybill !== undefined) params.hasWaybill = filters.hasWaybill
  86 + if (filters.pendingShipmentForm !== undefined) params.pendingShipmentForm = filters.pendingShipmentForm
83 87 if (filters.createTimeStart) params.createTimeStart = filters.createTimeStart
84 88 if (filters.createTimeEnd) params.createTimeEnd = filters.createTimeEnd
85 89 if (filters.payTimeStart) params.payTimeStart = filters.payTimeStart
... ... @@ -152,7 +156,7 @@ export const getMergedOrderDetail = (ids: number[]) =&gt; {
152 156 return api.get('/orders/detail/merged', { params: { ids: ids.join(',') } })
153 157 }
154 158  
155   -// 查询ERP商品列表
  159 +// 查询ERP商品列表(pl:单个品类 F_Id;ERP 商品可挂多品类逗号存储,后端按「包含该 ID」匹配)
156 160 export const searchProducts = (keyword?: string, pageIndex = 1, pageSize = 20, pl?: string) => {
157 161 const params: any = { pageIndex, pageSize }
158 162 if (keyword) params.keyword = keyword
... ...
Antis.Erp.Plat/douyin/frontend/src/utils/config.ts
... ... @@ -48,3 +48,14 @@ export const getBackendBaseUrl = (): string =&gt; {
48 48 return window.location.origin
49 49 }
50 50  
  51 +/** ERP(NCC) 站点根地址,用于拼接商品主图等相对路径(须与 DouyinLogistics.API 的 ErpApi.BaseUrl 一致) */
  52 +export const getErpBaseUrl = (): string => {
  53 + if (import.meta.env.VITE_ERP_BASE_URL) {
  54 + return String(import.meta.env.VITE_ERP_BASE_URL).replace(/\/$/, '')
  55 + }
  56 + if (import.meta.env.DEV) {
  57 + return 'http://localhost:2011'
  58 + }
  59 + return 'http://localhost:2011'
  60 +}
  61 +
... ...
Antis.Erp.Plat/douyin/frontend/src/views/CreateWaybillView.vue
... ... @@ -9,7 +9,7 @@
9 9 type="primary"
10 10 @click="handleSubmit"
11 11 :loading="submitting"
12   - :disabled="(waybillStatus !== undefined && waybillStatus >= 1) || form.status === 2 || form.status === 3"
  12 + :disabled="(waybillStatus !== undefined && waybillStatus >= 1) || form.status === 2 || form.status === 3 || form.status === 4"
13 13 size="small"
14 14 >
15 15 提交发货单
... ... @@ -36,17 +36,30 @@
36 36 </el-col>
37 37 <el-col :span="24">
38 38 <el-form-item label="订单状态">
39   - <el-tag :type="form.status === 0 ? 'warning' : form.status === 1 ? 'success' : form.status === 3 ? 'info' : 'info'" size="small">
40   - {{ form.status === 0 ? '待发货' : form.status === 1 ? '已发货' : form.status === 3 ? '已退款' : '已取消' }}
  39 + <el-tag
  40 + :type="form.status === 0 ? 'warning' : form.status === 1 ? 'success' : form.status === 3 ? 'info' : form.status === 4 ? 'warning' : 'info'"
  41 + size="small"
  42 + >
  43 + {{
  44 + form.status === 0
  45 + ? '待发货'
  46 + : form.status === 1
  47 + ? '已发货'
  48 + : form.status === 3
  49 + ? '已退款'
  50 + : form.status === 4
  51 + ? '退款中'
  52 + : '已取消'
  53 + }}
41 54 </el-tag>
42 55 <el-alert
43   - v-if="form.status === 2 || form.status === 3"
  56 + v-if="form.status === 2 || form.status === 3 || form.status === 4"
44 57 type="error"
45 58 :closable="false"
46 59 show-icon
47 60 style="margin-top: 8px;"
48 61 >
49   - 已取消或已退款的订单不允许进行任何操作
  62 + 已取消、已退款或退款中的订单不允许进行任何操作
50 63 </el-alert>
51 64 <el-alert
52 65 v-else-if="waybillStatus !== undefined && waybillStatus >= 1"
... ... @@ -289,7 +302,7 @@
289 302 {{ getSerialNumberTypeText(row) }}
290 303 </el-tag>
291 304 <el-icon
292   - v-if="row.spbm"
  305 + v-if="row.spbm || getSkuCode(row)"
293 306 style="cursor: pointer; color: #409eff;"
294 307 @click="refreshSerialNumberType(row)"
295 308 :title="'刷新序列号类型信息'"
... ... @@ -442,6 +455,7 @@ import { useRoute, useRouter } from &#39;vue-router&#39;
442 455 import { ElMessage, ElMessageBox } from 'element-plus'
443 456 import { Refresh } from '@element-plus/icons-vue'
444 457 import { getOrderDetail, getMergedOrderDetail, searchProducts as searchProductsAPI, manualCreateWaybill, getProductInfo as getProductInfoAPI, createSalesOrder, getWarehouses, getProductCategories, updateSellerRemark, checkStock, getDefaults } from '@/api/order'
  458 +import { getErpBaseUrl } from '@/utils/config'
445 459 import SerialNumberSelect from '@/components/SerialNumberSelect.vue'
446 460 import axios from 'axios'
447 461  
... ... @@ -458,6 +472,17 @@ const serialNumberSelectRef = ref()
458 472 // 商品信息缓存(用于存储序列号类型)
459 473 const productCache = new Map<string, any>()
460 474  
  475 +/** 商品主图:补全 ERP 相对路径,避免 el-image 请求失败 */
  476 +const resolveProductPicUrl = (pic: string | undefined | null): string => {
  477 + const s = (pic || '').trim()
  478 + if (!s) return ''
  479 + if (/^https?:\/\//i.test(s)) return s
  480 + if (s.startsWith('//')) return `https:${s}`
  481 + const base = getErpBaseUrl().replace(/\/$/, '')
  482 + if (s.startsWith('/')) return `${base}${s}`
  483 + return s
  484 +}
  485 +
461 486 // 注意:所有商品信息查询都通过Douyin API代理,避免CORS问题
462 487 // 不再直接访问主ERP系统
463 488  
... ... @@ -602,9 +627,10 @@ const loadOrderDetail = async () =&gt; {
602 627 }
603 628  
604 629 // 尝试多种可能的字段名
  630 + const rawPic = item.product_pic || item.ProductPic || item.pic || item.image || ''
605 631 const mappedItem = {
606 632 product_name: item.product_name || item.ProductName || item.spmc || item.Spmc || item.name || '',
607   - product_pic: item.product_pic || item.ProductPic || item.pic || item.image || '',
  633 + product_pic: resolveProductPicUrl(rawPic),
608 634 item_num: item.item_num || item.ItemNum || item.itemNum || item.quantity || item.num || 1,
609 635 goods_price: goodsPrice,
610 636 spec: item.spec || item.Spec || item.gg || item.Gg || item.specification || '',
... ... @@ -626,33 +652,7 @@ const loadOrderDetail = async () =&gt; {
626 652 })
627 653 console.log('最终商品清单:', form.value.productItems)
628 654  
629   - // 确保所有商品的序列号类型都已加载,并且spbm字段已正确设置
630   - await Promise.all(
631   - form.value.productItems.map(async (item: any) => {
632   - // 如果没有spbm但有sku_id,先通过sku_id查找spbm
633   - if (!item.spbm && getSkuCode(item)) {
634   - const skuId = getSkuCode(item)
635   - console.log('商品缺少spbm,通过sku_id查找:', { skuId, item })
636   - try {
637   - const response = await searchProductsAPI(skuId, 1, 1)
638   - if (response.data && response.data.data && response.data.data.length > 0) {
639   - const product = response.data.data[0]
640   - if (product.spbm) {
641   - item.spbm = product.spbm
642   - console.log('通过sku_id找到spbm:', { skuId, spbm: product.spbm })
643   - }
644   - }
645   - } catch (error) {
646   - console.error('通过sku_id查找spbm失败:', error)
647   - }
648   - }
649   -
650   - // 加载序列号类型
651   - if (item.spbm || getSkuCode(item)) {
652   - await loadSerialNumberType(item)
653   - }
654   - })
655   - )
  655 + await Promise.all(form.value.productItems.map((item: any) => loadSerialNumberType(item)))
656 656 } else {
657 657 console.log('没有商品清单数据')
658 658 form.value.productItems = []
... ... @@ -733,7 +733,7 @@ const addProduct = async (product: any) =&gt; {
733 733 const images = typeof product.spzt === 'string' ? JSON.parse(product.spzt) : product.spzt
734 734 if (Array.isArray(images) && images.length > 0 && images[0].url) {
735 735 const url = images[0].url
736   - productPic = url.startsWith('http') ? url : `http://localhost:2011${url}`
  736 + productPic = url.startsWith('http') ? url : `${getErpBaseUrl()}${url.startsWith('/') ? '' : '/'}${url}`
737 737 }
738 738 } catch (e) {
739 739 console.warn('解析商品主图失败:', e)
... ... @@ -743,7 +743,7 @@ const addProduct = async (product: any) =&gt; {
743 743 // 添加商品到清单
744 744 const newProduct = {
745 745 product_name: product.spmc || '',
746   - product_pic: productPic,
  746 + product_pic: resolveProductPicUrl(productPic || product.imageUrl || ''),
747 747 spbm: product.spbm || '',
748 748 sku_id: product.dyspid || '',
749 749 item_num: 1,
... ... @@ -804,32 +804,31 @@ const removeProduct = (index: number) =&gt; {
804 804 form.value.productItems.splice(index, 1)
805 805 }
806 806  
807   -// 获取商品信息(用于获取序列号类型)
808   -// 通过Douyin API代理请求,避免CORS问题
809   -const getProductInfo = async (productCode: string) => {
810   - const key = String(productCode)
811   - // 如果缓存中已有该商品信息,直接返回
812   - if (productCache.has(key)) {
813   - return productCache.get(key)
  807 +// 获取商品信息(用于获取序列号类型);可按商品编码或抖音 SKU 查询
  808 +const getProductInfo = async (productCode?: string, skuId?: string) => {
  809 + const code = productCode ? String(productCode).trim() : ''
  810 + const sku = skuId ? String(skuId).trim() : ''
  811 + const cacheKey = code ? code : sku ? `sku:${sku}` : ''
  812 + if (!cacheKey) return null
  813 + if (productCache.has(cacheKey)) {
  814 + return productCache.get(cacheKey)
814 815 }
815 816  
816 817 try {
817   - // 通过Douyin API代理请求,避免CORS问题
818   - console.log('查询商品信息 - 商品编码:', key)
819   - const response = await getProductInfoAPI(key)
820   -
821   - console.log('查询商品信息 - 响应:', response.data)
822   -
  818 + console.log('查询商品信息 - spbm:', code || '(无)', 'skuId:', sku || '(无)')
  819 + const response = await getProductInfoAPI(code || undefined, sku || undefined)
  820 +
823 821 if (response.data && response.data.code === 200 && response.data.data) {
824 822 const product = response.data.data
825   - product.spxlhType = String(product.spxlhType || '')
826   - productCache.set(key, product)
827   - console.log('获取商品信息成功:', { productCode: key, spxlhType: product.spxlhType, productName: product.spmc })
  823 + product.spxlhType = product.spxlhType != null && product.spxlhType !== '' ? String(product.spxlhType) : ''
  824 + productCache.set(cacheKey, product)
  825 + if (product.spbm) {
  826 + productCache.set(String(product.spbm), product)
  827 + }
  828 + console.log('获取商品信息成功:', { cacheKey, spxlhType: product.spxlhType, productName: product.spmc })
828 829 return product
829   - } else {
830   - console.warn('未找到商品信息:', key, response.data)
831 830 }
832   -
  831 + console.warn('未找到商品信息:', cacheKey, response.data)
833 832 return null
834 833 } catch (error) {
835 834 console.error('获取商品信息失败:', error)
... ... @@ -837,54 +836,52 @@ const getProductInfo = async (productCode: string) =&gt; {
837 836 }
838 837 }
839 838  
840   -// 加载序列号类型
  839 +// 加载序列号类型(始终在 finally 里标记 spxlhLoaded,避免一直显示「加载中」)
841 840 const loadSerialNumberType = async (item: any) => {
842   - // 如果没有spbm,尝试通过sku_id查找
843   - if (!item.spbm) {
  841 + item.spxlhLoaded = false
  842 + try {
844 843 const skuId = getSkuCode(item)
845   - if (skuId) {
  844 +
  845 + if (!item.spbm && skuId) {
  846 + const bySku = await getProductInfo(undefined, skuId)
  847 + if (bySku) {
  848 + if (bySku.spbm) item.spbm = String(bySku.spbm)
  849 + if (bySku.spxlhType !== undefined && bySku.spxlhType !== null && String(bySku.spxlhType) !== '') {
  850 + item.spxlhType = String(bySku.spxlhType)
  851 + }
  852 + if (bySku.imageUrl) {
  853 + item.product_pic = resolveProductPicUrl(bySku.imageUrl)
  854 + }
  855 + }
  856 + }
  857 +
  858 + if (!item.spbm && skuId) {
846 859 try {
847   - // 通过sku_id查找对应的ERP商品(使用Douyin API代理)
848 860 const response = await searchProductsAPI(skuId, 1, 1)
849   -
850   - if (response.data && response.data.data && response.data.data.length > 0) {
  861 + if (response.data?.data?.length > 0) {
851 862 const product = response.data.data[0]
852   - if (product.spbm) {
853   - // 更新item的spbm字段
854   - item.spbm = product.spbm
855   - console.log('通过sku_id找到商品编码:', { skuId, spbm: product.spbm, product })
856   - // 继续加载序列号类型
857   - } else {
858   - console.warn('通过sku_id查找商品,但未找到spbm:', { skuId, product })
859   - return
  863 + if (product.spbm) item.spbm = product.spbm
  864 + if (product.imageUrl) {
  865 + item.product_pic = resolveProductPicUrl(product.imageUrl)
860 866 }
861   - } else {
862   - console.warn('通过sku_id未找到商品:', skuId)
863   - return
864 867 }
865 868 } catch (error) {
866   - console.error('通过sku_id查找商品编码失败:', error)
867   - return
  869 + console.error('通过 sku 搜索商品失败:', error)
868 870 }
869   - } else {
870   - console.warn('商品没有spbm也没有sku_id:', item)
871   - return
872 871 }
873   - }
874   -
875   - if (!item.spbm) {
876   - console.warn('商品没有spbm,无法加载序列号类型:', item)
877   - return
878   - }
879   -
880   - item.spxlhLoaded = false
881   - try {
882   - const product = await getProductInfo(item.spbm)
883   - if (product && product.spxlhType) {
884   - item.spxlhType = String(product.spxlhType)
885   - console.log('序列号类型加载成功:', { spbm: item.spbm, spxlhType: item.spxlhType })
886   - } else {
887   - console.warn('未获取到序列号类型:', { spbm: item.spbm, product })
  872 +
  873 + if (item.spbm) {
  874 + const product = await getProductInfo(item.spbm)
  875 + if (product) {
  876 + if (product.spxlhType !== undefined && product.spxlhType !== null && String(product.spxlhType) !== '') {
  877 + item.spxlhType = String(product.spxlhType)
  878 + }
  879 + if (product.imageUrl) {
  880 + item.product_pic = resolveProductPicUrl(product.imageUrl)
  881 + }
  882 + }
  883 + } else if (!skuId) {
  884 + console.warn('商品没有 spbm 也没有 sku,跳过序列号类型:', item)
888 885 }
889 886 } catch (error) {
890 887 console.error('加载序列号类型失败:', error)
... ... @@ -943,11 +940,10 @@ const refreshSerialNumberType = async (row: any) =&gt; {
943 940 }
944 941 }
945 942  
946   - // 清除缓存
947   - const key = String(row.spbm)
948   - productCache.delete(key)
949   -
950   - // 重新加载
  943 + if (row.spbm) productCache.delete(String(row.spbm))
  944 + const sku = getSkuCode(row)
  945 + if (sku) productCache.delete(`sku:${sku}`)
  946 +
951 947 await loadSerialNumberType(row)
952 948 ElMessage.success('序列号类型已刷新')
953 949 } catch (error) {
... ... @@ -1260,6 +1256,11 @@ const handleSubmit = async () =&gt; {
1260 1256 ElMessage.error(`该发货单已${statusText},不允许重复提交`)
1261 1257 return
1262 1258 }
  1259 +
  1260 + if (form.value.status === 2 || form.value.status === 3 || form.value.status === 4) {
  1261 + ElMessage.error('已取消、已退款或退款中的订单不允许提交发货单')
  1262 + return
  1263 + }
1263 1264  
1264 1265 // 验证必填字段
1265 1266 if (!form.value.cjck) {
... ...
Antis.Erp.Plat/douyin/frontend/src/views/OrderListView.vue
... ... @@ -18,10 +18,11 @@
18 18 <el-select v-model="filterForm.status" placeholder="全部" clearable style="width: 150px">
19 19 <el-option label="全部" :value="undefined" />
20 20 <el-option label="待发货" :value="0" />
21   - <el-option label="已打印 未发货" value="printed_not_shipped" />
  21 + <el-option label="有运单·未提交发货单" value="printed_not_shipped" />
22 22 <el-option label="已发货" :value="1" />
23 23 <el-option label="已取消" :value="2" />
24 24 <el-option label="已退款" :value="3" />
  25 + <el-option label="退款中" :value="4" />
25 26 </el-select>
26 27 </el-form-item>
27 28 <el-form-item label="订单编号">
... ... @@ -282,7 +283,7 @@
282 283 type="primary"
283 284 size="small"
284 285 @click="handleCreateWaybill(row)"
285   - :disabled="row.status === 2"
  286 + :disabled="row.status === 2 || row.status === 3 || row.status === 4"
286 287 >
287 288 创建运单
288 289 </el-button>
... ... @@ -339,7 +340,7 @@ const batchLoading = ref(false)
339 340 const orders = ref<Order[]>([])
340 341 const selectedOrders = ref<Order[]>([])
341 342 const filterForm = ref({
342   - status: 0 as number | string | undefined, // 默认显示待发货订单,支持特殊字符串 'printed_not_shipped'
  343 + status: 0 as number | string | undefined, // 默认待发货;'printed_not_shipped' → 有运单且未提交发货单(未写 SalesOrderId)
343 344 orderId: '',
344 345 receiverName: '',
345 346 receiverPhone: '',
... ... @@ -363,7 +364,7 @@ const fetchOrders = async () =&gt; {
363 364  
364 365 if (filterForm.value.status === 'printed_not_shipped') {
365 366 filters.status = 0
366   - filters.hasWaybill = true
  367 + filters.pendingShipmentForm = true
367 368 } else if (filterForm.value.status !== undefined) {
368 369 filters.status = filterForm.value.status
369 370 }
... ... @@ -623,10 +624,10 @@ const handleBatchCreateWaybill = async () =&gt; {
623 624  
624 625 // 过滤掉已取消、已退款的订单
625 626 const validOrders = selectedOrders.value.filter((order: Order) => order.status === 0 || order.status === 1)
626   - const invalidOrders = selectedOrders.value.filter((order: Order) => order.status === 2 || order.status === 3)
  627 + const invalidOrders = selectedOrders.value.filter((order: Order) => order.status === 2 || order.status === 3 || order.status === 4)
627 628  
628 629 if (invalidOrders.length > 0) {
629   - ElMessage.warning(`已过滤 ${invalidOrders.length} 个已取消/已退款的订单,不允许创建运单`)
  630 + ElMessage.warning(`已过滤 ${invalidOrders.length} 个已取消/已退款/退款中的订单,不允许创建运单`)
630 631 }
631 632  
632 633 if (validOrders.length === 0) {
... ... @@ -719,6 +720,10 @@ const handleBatchCreateWaybill = async () =&gt; {
719 720  
720 721 // 创建运单(合并单跳转到合并编辑页;单笔订单走原有流程)
721 722 const handleCreateWaybill = async (order: Order) => {
  723 + if (order.status === 2 || order.status === 3 || order.status === 4) {
  724 + ElMessage.warning('已取消、已退款或退款中的订单不允许创建运单')
  725 + return
  726 + }
722 727 if (order.mergedOrderIds && order.mergedOrderIds.length > 1) {
723 728 handleManualCreateWaybill(order)
724 729 return
... ... @@ -898,7 +903,8 @@ const getStatusText = (status: number) =&gt; {
898 903 0: '待发货',
899 904 1: '已发货',
900 905 2: '已取消',
901   - 3: '已退款'
  906 + 3: '已退款',
  907 + 4: '退款中'
902 908 }
903 909 return statusMap[status] || '未知'
904 910 }
... ... @@ -909,7 +915,8 @@ const getStatusType = (status: number) =&gt; {
909 915 0: 'info',
910 916 1: 'success',
911 917 2: 'danger',
912   - 3: 'warning'
  918 + 3: 'warning',
  919 + 4: 'warning'
913 920 }
914 921 return typeMap[status] || 'info'
915 922 }
... ...
Antis.Erp.Plat/netcore/src/Application/NCC.API/Properties/launchSettings.json
1   -{
  1 +{
2 2 "iisSettings": {
3 3 "windowsAuthentication": false,
4 4 "anonymousAuthentication": true,
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/WtSpCrInput.cs
... ... @@ -15,7 +15,7 @@ namespace NCC.Extend.Entitys.Dto.WtSp
15 15 public string spmc { get; set; }
16 16  
17 17 /// <summary>
18   - /// 商品品类
  18 + /// 商品品类(多个 wt_pl.F_Id 用英文逗号分隔,如 id1,id2)
19 19 /// </summary>
20 20 public string pl { get; set; }
21 21  
... ...
Antis.Erp.Plat/netcore/src/Modularity/Extend/NCC.Extend/WtSpService.cs
... ... @@ -51,6 +51,16 @@ namespace NCC.Extend.WtSp
51 51 }
52 52  
53 53 /// <summary>
  54 + /// 按品类筛选:F_Pl 为单个 ID 或逗号分隔多 ID 时,均用 MySQL FIND_IN_SET 匹配(避免 LINQ Contains/StartsWith 在部分环境下对多值字段翻译异常)。
  55 + /// </summary>
  56 + private static ISugarQueryable<WtSpEntity> WherePlCategoryIfAny(ISugarQueryable<WtSpEntity> query, string pl)
  57 + {
  58 + var id = pl?.Trim();
  59 + if (string.IsNullOrEmpty(id)) return query;
  60 + return query.Where("FIND_IN_SET(@spPlCategory,`F_Pl`)>0", new { spPlCategory = id });
  61 + }
  62 +
  63 + /// <summary>
54 64 /// 获取商品档案
55 65 /// </summary>
56 66 /// <param name="id">参数</param>
... ... @@ -84,9 +94,8 @@ namespace NCC.Extend.WtSp
84 94 List<object> queryKc = input.kc != null ? input.kc.Split(',').ToObeject<List<object>>() : null;
85 95 var startKc = input.kc != null && !string.IsNullOrEmpty(queryKc.First().ToString()) ? queryKc.First() : decimal.MinValue;
86 96 var endKc = input.kc != null && !string.IsNullOrEmpty(queryKc.Last().ToString()) ? queryKc.Last() : decimal.MaxValue;
87   - var data = await _db.Queryable<WtSpEntity>()
88   - .WhereIF(!string.IsNullOrEmpty(input.spmc), p => p.Spmc.Contains(input.spmc))
89   - .WhereIF(!string.IsNullOrEmpty(input.pl), p => p.Pl.Equals(input.pl))
  97 + var data = await WherePlCategoryIfAny(_db.Queryable<WtSpEntity>()
  98 + .WhereIF(!string.IsNullOrEmpty(input.spmc), p => p.Spmc.Contains(input.spmc)), input.pl)
90 99 .WhereIF(!string.IsNullOrEmpty(input.pp), p => p.Pp.Equals(input.pp))
91 100 .WhereIF(!string.IsNullOrEmpty(input.spbm), p => p.Spbm.Contains(input.spbm))
92 101 .WhereIF(!string.IsNullOrEmpty(input.spxlhType), p => p.SpxlhType.Equals(input.spxlhType))
... ... @@ -102,9 +111,7 @@ namespace NCC.Extend.WtSp
102 111 {
103 112 id = it.Id,
104 113 spmc=it.Spmc,
105   - //pl=it.Pl,
106   - pl=SqlFunc.Subqueryable<WtPlEntity>().Where(u=>u.Id==it.Pl).Select(u=>u.Plmc),
107   - // pp=it.Pp,
  114 + pl=it.Pl,
108 115 pp=SqlFunc.Subqueryable<WtPpEntity>().Where(u=>u.Id==it.Pp).Select(u=>u.Ppmc),
109 116 spbm=it.Spbm,
110 117 spxlhType=it.SpxlhType,
... ... @@ -159,6 +166,7 @@ namespace NCC.Extend.WtSp
159 166 {
160 167 item.mdkc = stockDict.ContainsKey(item.id) ? stockDict[item.id].ToString() : "0";
161 168 }
  169 + await ResolvePlDisplayAsync(resultList);
162 170 await ResolveHyxzDisplayAsync(resultList);
163 171 }
164 172 }
... ... @@ -187,18 +195,13 @@ namespace NCC.Extend.WtSp
187 195 var sidx = input.sidx == null ? "id" : input.sidx;
188 196  
189 197  
190   - var data = await _db.Queryable<WtSpEntity>()
191   - .WhereIF(!string.IsNullOrEmpty(input.keyword), p => p.Spmc.Contains(input.keyword)||p.Spbm.Contains(input.keyword)||p.Dyspid.Contains(input.keyword))
192   - .WhereIF(!string.IsNullOrEmpty(input.pl), p => p.Pl.Equals(input.pl))
193   -
194   -
  198 + var data = await WherePlCategoryIfAny(_db.Queryable<WtSpEntity>()
  199 + .WhereIF(!string.IsNullOrEmpty(input.keyword), p => p.Spmc.Contains(input.keyword)||p.Spbm.Contains(input.keyword)||p.Dyspid.Contains(input.keyword)), input.pl)
195 200 .Select(it=> new WtSpListOutput
196 201 {
197 202 id = it.Id,
198 203 spmc=it.Spmc,
199   - //pl=it.Pl,
200   - pl=SqlFunc.Subqueryable<WtPlEntity>().Where(u=>u.Id==it.Pl).Select(u=>u.Plmc),
201   - // pp=it.Pp,
  204 + pl=it.Pl,
202 205 pp=SqlFunc.Subqueryable<WtPpEntity>().Where(u=>u.Id==it.Pp).Select(u=>u.Ppmc),
203 206 spbm=it.Spbm,
204 207 spxlhType=it.SpxlhType,
... ... @@ -255,6 +258,7 @@ namespace NCC.Extend.WtSp
255 258 {
256 259 item.mdkc = stockDict.ContainsKey(item.id) ? stockDict[item.id].ToString() : "0";
257 260 }
  261 + await ResolvePlDisplayAsync(resultList);
258 262 await ResolveHyxzDisplayAsync(resultList);
259 263 }
260 264 }
... ... @@ -267,6 +271,44 @@ namespace NCC.Extend.WtSp
267 271 return pageResult;
268 272 }
269 273  
  274 + /// <summary>
  275 + /// 规范化商品品类:去空格、去重、去空段。
  276 + /// </summary>
  277 + private static string NormalizePlCsv(string pl)
  278 + {
  279 + if (string.IsNullOrWhiteSpace(pl)) return pl;
  280 + var parts = pl.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  281 + .Where(s => !string.IsNullOrEmpty(s))
  282 + .Distinct(StringComparer.Ordinal)
  283 + .ToList();
  284 + return parts.Count == 0 ? null : string.Join(",", parts);
  285 + }
  286 +
  287 + /// <summary>
  288 + /// 商品品类 F_Pl 支持逗号分隔多个品类 ID;列表接口将 pl 字段解析为「品类名、品类名」展示。
  289 + /// </summary>
  290 + private async Task ResolvePlDisplayAsync(List<WtSpListOutput> list)
  291 + {
  292 + var allIds = list
  293 + .Where(p => !string.IsNullOrWhiteSpace(p.pl))
  294 + .SelectMany(p => p.pl.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  295 + .Distinct()
  296 + .ToList();
  297 + if (allIds.Count == 0) return;
  298 + var plRows = await _db.Queryable<WtPlEntity>()
  299 + .Where(u => allIds.Contains(u.Id))
  300 + .Select(u => new { u.Id, u.Plmc })
  301 + .ToListAsync();
  302 + var dict = plRows.ToDictionary(x => x.Id, x => x.Plmc ?? "");
  303 + foreach (var item in list)
  304 + {
  305 + if (string.IsNullOrWhiteSpace(item.pl)) continue;
  306 + var ids = item.pl.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  307 + var names = ids.Select(id => dict.TryGetValue(id, out var n) && !string.IsNullOrEmpty(n) ? n : id).ToList();
  308 + item.pl = names.Count > 0 ? string.Join("、", names) : item.pl;
  309 + }
  310 + }
  311 +
270 312 private async Task ResolveHyxzDisplayAsync(List<WtSpListOutput> list)
271 313 {
272 314 var ids = list.Where(p => !string.IsNullOrWhiteSpace(p.hyxz))
... ... @@ -341,6 +383,7 @@ namespace NCC.Extend.WtSp
341 383  
342 384 var entity = input.Adapt<WtSpEntity>();
343 385 entity.Id = YitIdHelper.NextId().ToString();
  386 + entity.Pl = NormalizePlCsv(entity.Pl);
344 387  
345 388 // Console.WriteLine($"映射后的实体: {System.Text.Json.JsonSerializer.Serialize(entity)}");
346 389  
... ... @@ -373,9 +416,8 @@ namespace NCC.Extend.WtSp
373 416 List<object> queryKc = input.kc != null ? input.kc.Split(',').ToObeject<List<object>>() : null;
374 417 var startKc = input.kc != null && !string.IsNullOrEmpty(queryKc.First().ToString()) ? queryKc.First() : decimal.MinValue;
375 418 var endKc = input.kc != null && !string.IsNullOrEmpty(queryKc.Last().ToString()) ? queryKc.Last() : decimal.MaxValue;
376   - var data = await _db.Queryable<WtSpEntity>()
377   - .WhereIF(!string.IsNullOrEmpty(input.spmc), p => p.Spmc.Contains(input.spmc))
378   - .WhereIF(!string.IsNullOrEmpty(input.pl), p => p.Pl.Equals(input.pl))
  419 + var data = await WherePlCategoryIfAny(_db.Queryable<WtSpEntity>()
  420 + .WhereIF(!string.IsNullOrEmpty(input.spmc), p => p.Spmc.Contains(input.spmc)), input.pl)
379 421 .WhereIF(!string.IsNullOrEmpty(input.pp), p => p.Pp.Equals(input.pp))
380 422 .WhereIF(!string.IsNullOrEmpty(input.spbm), p => p.Spbm.Contains(input.spbm))
381 423 .WhereIF(!string.IsNullOrEmpty(input.spxlhType), p => p.SpxlhType.Equals(input.spxlhType))
... ... @@ -444,6 +486,9 @@ namespace NCC.Extend.WtSp
444 486 item.mdkc = "0";
445 487 }
446 488 }
  489 +
  490 + await ResolvePlDisplayAsync(typedData);
  491 + await ResolveHyxzDisplayAsync(typedData);
447 492 }
448 493  
449 494 return typedData;
... ... @@ -571,6 +616,7 @@ namespace NCC.Extend.WtSp
571 616 // Console.WriteLine($"接收到的输入数据: {System.Text.Json.JsonSerializer.Serialize(input)}");
572 617  
573 618 var entity = input.Adapt<WtSpEntity>();
  619 + entity.Pl = NormalizePlCsv(entity.Pl);
574 620  
575 621 // Console.WriteLine($"映射后的实体: {System.Text.Json.JsonSerializer.Serialize(entity)}");
576 622  
... ...