Commit dc39baae10e931c4b15bf334b1cfcaa493800bd4

Authored by 李曜臣
1 parent c2e3194d

后台端:管理员查看日志优化,产品与门店绑定优化;

app:分类优化
标签模块接口对接说明.md
@@ -582,7 +582,7 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -582,7 +582,7 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
582 582
583 方法:`POST /api/app/product` 583 方法:`POST /api/app/product`
584 584
585 -入参(Body:`ProductCreateInputVo`) 585 +入参(Body:`ProductCreateInputVo`)示例 1(自填编码)
586 ```json 586 ```json
587 { 587 {
588 "productCode": "PRD_TEST_001", 588 "productCode": "PRD_TEST_001",
@@ -597,10 +597,22 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -597,10 +597,22 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
597 } 597 }
598 ``` 598 ```
599 599
  600 +示例 2(不传或 `null` 产品编码时,由后端生成唯一值,形如 `PRD_` + 32 位十六进制):
  601 +```json
  602 +{
  603 + "productCode": null,
  604 + "productName": "Chicken",
  605 + "categoryId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  606 + "productImageUrl": null,
  607 + "state": true,
  608 + "locationIds": []
  609 +}
  610 +```
  611 +
600 字段说明: 612 字段说明:
601 | 字段 | 类型 | 必填 | 说明 | 613 | 字段 | 类型 | 必填 | 说明 |
602 |------|------|------|------| 614 |------|------|------|------|
603 -| `productCode` | string | 是 | 产品编码 | 615 +| `productCode` | string \| null | **否** | **可选。** 有非空值时按该编码落库,且须全局唯一(未删除数据);不传、`null` 或空串时由后端 **自动生成**唯一 `productCode`。 |
604 | `productName` | string | 是 | 产品名称 | 616 | `productName` | string | 是 | 产品名称 |
605 | `categoryId` | string \| null | 否 | 产品分类 Id(`fl_product_category.id`) | 617 | `categoryId` | string \| null | 否 | 产品分类 Id(`fl_product_category.id`) |
606 | `productImageUrl` | string \| null | 否 | 主图 URL | 618 | `productImageUrl` | string \| null | 否 | 主图 URL |
@@ -608,8 +620,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -608,8 +620,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
608 | **`locationIds`** | `string[]` \| **省略** | 否 | **可选。** 有该字段时:在同一事务内按列表批量写入 **`fl_location_product`**(**每个门店 Id 一行**,即「一产品一门店一条关联」)。**请求体中省略该字段**时:本接口不写门店关联,仍可通过 **§7 Product-Location** 维护。传空数组 `[]` 表示新建产品后不绑定任何门店。 | 620 | **`locationIds`** | `string[]` \| **省略** | 否 | **可选。** 有该字段时:在同一事务内按列表批量写入 **`fl_location_product`**(**每个门店 Id 一行**,即「一产品一门店一条关联」)。**请求体中省略该字段**时:本接口不写门店关联,仍可通过 **§7 Product-Location** 维护。传空数组 `[]` 表示新建产品后不绑定任何门店。 |
609 621
610 校验: 622 校验:
611 -- `productCode` / `productName` 不能为空  
612 -- `productCode` 不能与未删除的数据重复 623 +- `productName` 不能为空
  624 +- 若请求中 **`productCode` 有非空值**:不能与**其它**未删除产品的 `productCode` 重复
613 - 若传入 **`locationIds`** 且含非空项:每个 Id 须为合法 Guid,且对应门店存在于 **`Location`** 主数据且未删除;否则返回友好错误(如「门店Id格式不正确」「门店不存在」) 625 - 若传入 **`locationIds`** 且含非空项:每个 Id 须为合法 Guid,且对应门店存在于 **`Location`** 主数据且未删除;否则返回友好错误(如「门店Id格式不正确」「门店不存在」)
614 626
615 ### 6.4 编辑产品 627 ### 6.4 编辑产品
@@ -620,6 +632,9 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -620,6 +632,9 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
620 - Path:`id` 为当前产品Id(`fl_product.Id`) 632 - Path:`id` 为当前产品Id(`fl_product.Id`)
621 - Body:字段同新增(`ProductUpdateInputVo`,继承 `ProductCreateInputVo`) 633 - Body:字段同新增(`ProductUpdateInputVo`,继承 `ProductCreateInputVo`)
622 634
  635 +**`productCode` 行为:**
  636 +- 不传、`null` 或空串:**保留**原产品的 `productCode`;若原数据异常为空,则按新增规则自动生成唯一编码。
  637 +
623 **`locationIds` 行为(与新增不同,请注意):** 638 **`locationIds` 行为(与新增不同,请注意):**
624 - **请求体中省略 `locationIds` 属性**:不修改 **`fl_location_product`**(仅更新 `fl_product` 主表字段;兼容原「先 PUT 产品再调 §7 同步门店」的调用方式)。 639 - **请求体中省略 `locationIds` 属性**:不修改 **`fl_location_product`**(仅更新 `fl_product` 主表字段;兼容原「先 PUT 产品再调 §7 同步门店」的调用方式)。
625 - **请求体中包含 `locationIds` 属性**(含空数组 `[]`):对该产品的门店关联做 **整表替换**——先删除本产品下全部 **`fl_location_product`** 行,再按列表逐条插入;`[]` 表示解除该产品与所有门店的关联。 640 - **请求体中包含 `locationIds` 属性**(含空数组 `[]`):对该产品的门店关联做 **整表替换**——先删除本产品下全部 **`fl_location_product`** 行,再按列表逐条插入;`[]` 表示解除该产品与所有门店的关联。
@@ -734,7 +749,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -734,7 +749,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
734 - **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。 749 - **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。
735 - **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。 750 - **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。
736 - **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。 751 - **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。
737 -- **第四级去重**:同一产品在同一标签分类、同一门店下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。 752 +- **第三级拆卡**:同一 `productId` 若同时存在多套 **`fl_label.TemplateId`**(不同标签模板),在 **`products`** 中拆成 **多条 L3**(`productId` 可相同,以 **`templateId`** 区分);`itemCount` 为卡片条数。
  753 +- **第四级去重**:在同一 L3 卡片(同一产品、同一模板)下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
738 754
739 #### 出参(`List<UsAppLabelCategoryTreeNodeDto>`) 755 #### 出参(`List<UsAppLabelCategoryTreeNodeDto>`)
740 756
@@ -762,14 +778,17 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -762,14 +778,17 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
762 | `buttonAppearance` | string | **JSON 格式字符串**(与库中一致;空时默认 `"TEXT"`) | 778 | `buttonAppearance` | string | **JSON 格式字符串**(与库中一致;空时默认 `"TEXT"`) |
763 | `availabilityType` | string | `ALL` / `SPECIFIED`(树已按当前门店过滤,仍返回供客户端展示) | 779 | `availabilityType` | string | `ALL` / `SPECIFIED`(树已按当前门店过滤,仍返回供客户端展示) |
764 | `orderNum` | number | 排序 | 780 | `orderNum` | number | 排序 |
765 -| `itemCount` | number | 该分类下 **产品个数**(去重后的产品数) |  
766 -| `products` | array | 第三级产品列表(见下表) | 781 +| `itemCount` | number | 该分类下 **L3 产品卡片条数**(同一产品多模板会多张卡) |
  782 +| `products` | array | 第三级产品卡片列表(见下表) |
767 783
768 -**L3 `UsAppLabelingProductNodeDto`(产品)** 784 +**L3 `UsAppLabelingProductNodeDto`(产品卡片,按模板拆分)**
769 785
770 | 字段 | 类型 | 说明 | 786 | 字段 | 类型 | 说明 |
771 |------|------|------| 787 |------|------|------|
772 | `productId` | string | `fl_product.Id` | 788 | `productId` | string | `fl_product.Id` |
  789 +| `templateId` | string | 当前卡片对应 **`fl_label.TemplateId`**;与 `productId` 组合唯一标识一张卡 |
  790 +| `templateCode` | string \| null | 当前卡片所用模板编码 |
  791 +| `templateLabelSizeText` | string \| null | 当前卡片模板尺寸文案(与四级中该模板尺寸一致) |
773 | `productName` | string | 产品名称 | 792 | `productName` | string | 产品名称 |
774 | `productCode` | string | 产品编码 | 793 | `productCode` | string | 产品编码 |
775 | `productImageUrl` | string \| null | 主图 | 794 | `productImageUrl` | string \| null | 主图 |
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
@@ -155,7 +155,7 @@ @@ -155,7 +155,7 @@
155 <view class="food-grid"> 155 <view class="food-grid">
156 <view 156 <view
157 v-for="product in pCat.products" 157 v-for="product in pCat.products"
158 - :key="product.productId" 158 + :key="productCardKey(product)"
159 class="food-card" 159 class="food-card"
160 @click="handleProductClick(product, pCat.name)" 160 @click="handleProductClick(product, pCat.name)"
161 > 161 >
@@ -468,8 +468,16 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string { @@ -468,8 +468,16 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
468 return resolveMediaUrlForApp(p.productImageUrl) 468 return resolveMediaUrlForApp(p.productImageUrl)
469 } 469 }
470 470
  471 +/** 同一 productId 多模板拆卡时保证列表 :key 唯一 */
  472 +function productCardKey(p: UsAppLabelingProductNodeDto): string {
  473 + const tid = (p.templateId ?? '').trim()
  474 + return tid ? `${p.productId}|${tid}` : p.productId
  475 +}
  476 +
471 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */ 477 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
472 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string { 478 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
  479 + const direct = (p.templateLabelSizeText ?? '').trim()
  480 + if (direct) return direct
473 const types = p.labelTypes || [] 481 const types = p.labelTypes || []
474 if (types.length === 0) return '—' 482 if (types.length === 0) return '—'
475 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean) 483 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
@@ -39,6 +39,11 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD @@ -39,6 +39,11 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD
39 })) 39 }))
40 return { 40 return {
41 productId: String(x?.productId ?? x?.ProductId ?? ''), 41 productId: String(x?.productId ?? x?.ProductId ?? ''),
  42 + templateId: (x?.templateId ?? x?.TemplateId ?? null) as string | null,
  43 + templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null,
  44 + templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as
  45 + | string
  46 + | null,
42 productName: String(x?.productName ?? x?.ProductName ?? ''), 47 productName: String(x?.productName ?? x?.ProductName ?? ''),
43 productCode: String(x?.productCode ?? x?.ProductCode ?? ''), 48 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
44 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, 49 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null,
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
@@ -11,6 +11,11 @@ export interface UsAppLabelTypeNodeDto { @@ -11,6 +11,11 @@ export interface UsAppLabelTypeNodeDto {
11 11
12 export interface UsAppLabelingProductNodeDto { 12 export interface UsAppLabelingProductNodeDto {
13 productId: string 13 productId: string
  14 + /** 与 productId 组合唯一标识一张卡(多模板拆卡) */
  15 + templateId?: string | null
  16 + templateCode?: string | null
  17 + /** 当前卡片模板尺寸,与接口 templateLabelSizeText 对齐 */
  18 + templateLabelSizeText?: string | null
14 productName: string 19 productName: string
15 productCode: string 20 productCode: string
16 productImageUrl: string | null 21 productImageUrl: string | null
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
@@ -4,7 +4,10 @@ namespace FoodLabeling.Application.Contracts.Dtos.Product; @@ -4,7 +4,10 @@ namespace FoodLabeling.Application.Contracts.Dtos.Product;
4 4
5 public class ProductCreateInputVo 5 public class ProductCreateInputVo
6 { 6 {
7 - public string ProductCode { get; set; } = string.Empty; 7 + /// <summary>
  8 + /// 可选。不传或空则创建时由后端生成唯一编码(如 PRD_xxxxxxxx)。
  9 + /// </summary>
  10 + public string? ProductCode { get; set; }
8 11
9 public string ProductName { get; set; } = string.Empty; 12 public string ProductName { get; set; } = string.Empty;
10 13
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs
1 namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling; 1 namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
2 2
3 /// <summary> 3 /// <summary>
4 -/// 第三级:产品 4 +/// 第三级:产品卡片(同一产品 Id 若存在多套标签模板,按 <c>TemplateId</c> 拆成多条,便于端上多卡展示)
5 /// </summary> 5 /// </summary>
6 public class UsAppLabelingProductNodeDto 6 public class UsAppLabelingProductNodeDto
7 { 7 {
8 public string ProductId { get; set; } = string.Empty; 8 public string ProductId { get; set; } = string.Empty;
9 9
  10 + /// <summary>当前卡片对应 <c>fl_label.TemplateId</c>;与 <c>ProductId</c> 共同唯一标识一张卡</summary>
  11 + public string TemplateId { get; set; } = string.Empty;
  12 +
  13 + /// <summary>当前卡片所用模板编码(与四级节点一致)</summary>
  14 + public string? TemplateCode { get; set; }
  15 +
  16 + /// <summary>当前卡片模板尺寸文案(如 6.00x12.00cm)</summary>
  17 + public string? TemplateLabelSizeText { get; set; }
  18 +
10 public string ProductName { get; set; } = string.Empty; 19 public string ProductName { get; set; } = string.Empty;
11 20
12 public string ProductCode { get; set; } = string.Empty; 21 public string ProductCode { get; set; } = string.Empty;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
@@ -24,6 +24,7 @@ public interface IProductAppService : IApplicationService @@ -24,6 +24,7 @@ public interface IProductAppService : IApplicationService
24 /// 新增产品 24 /// 新增产品
25 /// </summary> 25 /// </summary>
26 /// <remarks> 26 /// <remarks>
  27 + /// <see cref="ProductCreateInputVo.ProductCode"/> 可选;为空时后端生成唯一编码(如 PRD_ + Guid)。
27 /// 若 <see cref="ProductCreateInputVo.LocationIds"/> 有值,将在同一事务内批量写入 fl_location_product(一门店一条)。 28 /// 若 <see cref="ProductCreateInputVo.LocationIds"/> 有值,将在同一事务内批量写入 fl_location_product(一门店一条)。
28 /// </remarks> 29 /// </remarks>
29 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input); 30 Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input);
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
@@ -5,7 +5,7 @@ using Volo.Abp.Application.Services; @@ -5,7 +5,7 @@ using Volo.Abp.Application.Services;
5 namespace FoodLabeling.Application.Contracts.IServices; 5 namespace FoodLabeling.Application.Contracts.IServices;
6 6
7 /// <summary> 7 /// <summary>
8 -/// App Labeling:四级列表(标签分类 → 产品分类 → 产品 → 标签种类) 8 +/// App Labeling:四级列表(标签分类 → 产品分类 → 产品卡片「按模板拆分」→ 标签种类)
9 /// </summary> 9 /// </summary>
10 public interface IUsAppLabelingAppService : IApplicationService 10 public interface IUsAppLabelingAppService : IApplicationService
11 { 11 {
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
1 using Volo.Abp.Users; 1 using Volo.Abp.Users;
  2 +using Yi.Framework.Rbac.Domain.Shared.Consts;
2 3
3 namespace FoodLabeling.Application.Helpers; 4 namespace FoodLabeling.Application.Helpers;
4 5
5 /// <summary> 6 /// <summary>
6 -/// Reports 模块角色判断(与 JWT / CurrentUser.Roles 中的角色码一致) 7 +/// Reports 模块角色判断(与 JWT 中角色声明一致)
7 /// </summary> 8 /// </summary>
8 public static class ReportsRoleHelper 9 public static class ReportsRoleHelper
9 { 10 {
10 /// <summary> 11 /// <summary>
11 - /// 是否为管理员:任一角色码等于 <c>admin</c>(忽略大小写)则视为可查看全部打印数据。 12 + /// 是否为「可查看全部用户打印数据」的管理员:
  13 + /// <list type="bullet">
  14 + /// <item>标准 <see cref="ICurrentUser.Roles"/> 中含角色码 <c>admin</c>(普通账号绑定 RoleCode=admin 时走此路径);</item>
  15 + /// <item>内置超管:用户名 <c>admin</c> 时 JWT 使用自定义 claim <c>Roles</c>,不写多条 <c>role</c>,需单独识别;</item>
  16 + /// <item>超管权限 claim <c>Permission</c> 为 <c>*:*:*</c> 时视为管理员。</item>
  17 + /// </list>
12 /// </summary> 18 /// </summary>
13 public static bool IsAdminRole(ICurrentUser currentUser) 19 public static bool IsAdminRole(ICurrentUser currentUser)
14 { 20 {
15 - if (currentUser.Roles is null) 21 + if (currentUser.Id is null)
16 { 22 {
17 return false; 23 return false;
18 } 24 }
19 25
20 - foreach (var r in currentUser.Roles) 26 + var userName = currentUser.UserName?.Trim();
  27 + if (!string.IsNullOrWhiteSpace(userName) &&
  28 + string.Equals(userName, UserConst.Admin, StringComparison.OrdinalIgnoreCase))
21 { 29 {
22 - if (!string.IsNullOrWhiteSpace(r) &&  
23 - string.Equals(r.Trim(), "admin", StringComparison.OrdinalIgnoreCase)) 30 + return true;
  31 + }
  32 +
  33 + foreach (var c in currentUser.FindClaims(TokenTypeConst.Permission))
  34 + {
  35 + if (!string.IsNullOrWhiteSpace(c.Value) &&
  36 + string.Equals(c.Value.Trim(), UserConst.AdminPermissionCode, StringComparison.Ordinal))
24 { 37 {
25 return true; 38 return true;
26 } 39 }
27 } 40 }
28 41
  42 + var rolesClaim = currentUser.FindClaims(TokenTypeConst.Roles).Select(x => x.Value).FirstOrDefault();
  43 + if (!string.IsNullOrWhiteSpace(rolesClaim))
  44 + {
  45 + foreach (var part in rolesClaim.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  46 + {
  47 + if (string.Equals(part, UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase))
  48 + {
  49 + return true;
  50 + }
  51 + }
  52 + }
  53 +
  54 + if (currentUser.Roles is not null)
  55 + {
  56 + foreach (var r in currentUser.Roles)
  57 + {
  58 + if (!string.IsNullOrWhiteSpace(r) &&
  59 + string.Equals(r.Trim(), UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase))
  60 + {
  61 + return true;
  62 + }
  63 + }
  64 + }
  65 +
29 return false; 66 return false;
30 } 67 }
31 } 68 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
@@ -163,18 +163,25 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -163,18 +163,25 @@ public class ProductAppService : ApplicationService, IProductAppService
163 [UnitOfWork] 163 [UnitOfWork]
164 public async Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input) 164 public async Task<ProductGetOutputDto> CreateAsync(ProductCreateInputVo input)
165 { 165 {
166 - var code = input.ProductCode?.Trim();  
167 var name = input.ProductName?.Trim(); 166 var name = input.ProductName?.Trim();
168 - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) 167 + if (string.IsNullOrWhiteSpace(name))
169 { 168 {
170 - throw new UserFriendlyException("产品编码和名称不能为空"); 169 + throw new UserFriendlyException("产品名称不能为空");
171 } 170 }
172 171
173 - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()  
174 - .AnyAsync(x => !x.IsDeleted && (x.ProductCode == code));  
175 - if (duplicated) 172 + var code = input.ProductCode?.Trim();
  173 + if (string.IsNullOrWhiteSpace(code))
  174 + {
  175 + code = await GenerateUniqueProductCodeAsync();
  176 + }
  177 + else
176 { 178 {
177 - throw new UserFriendlyException("产品编码已存在"); 179 + var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  180 + .AnyAsync(x => !x.IsDeleted && x.ProductCode == code);
  181 + if (duplicated)
  182 + {
  183 + throw new UserFriendlyException("产品编码已存在");
  184 + }
178 } 185 }
179 186
180 var entity = new FlProductDbEntity 187 var entity = new FlProductDbEntity
@@ -215,18 +222,27 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -215,18 +222,27 @@ public class ProductAppService : ApplicationService, IProductAppService
215 throw new UserFriendlyException("产品不存在"); 222 throw new UserFriendlyException("产品不存在");
216 } 223 }
217 224
218 - var code = input.ProductCode?.Trim();  
219 var name = input.ProductName?.Trim(); 225 var name = input.ProductName?.Trim();
220 - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) 226 + if (string.IsNullOrWhiteSpace(name))
221 { 227 {
222 - throw new UserFriendlyException("产品编码和名称不能为空"); 228 + throw new UserFriendlyException("产品名称不能为空");
223 } 229 }
224 230
225 - var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()  
226 - .AnyAsync(x => !x.IsDeleted && x.Id != productId && x.ProductCode == code);  
227 - if (duplicated) 231 + var codeInput = input.ProductCode?.Trim();
  232 + var code = string.IsNullOrWhiteSpace(codeInput) ? entity.ProductCode : codeInput;
  233 + if (string.IsNullOrWhiteSpace(code))
228 { 234 {
229 - throw new UserFriendlyException("产品编码已存在"); 235 + code = await GenerateUniqueProductCodeAsync();
  236 + }
  237 +
  238 + if (code != entity.ProductCode)
  239 + {
  240 + var duplicated = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  241 + .AnyAsync(x => !x.IsDeleted && x.Id != productId && x.ProductCode == code);
  242 + if (duplicated)
  243 + {
  244 + throw new UserFriendlyException("产品编码已存在");
  245 + }
230 } 246 }
231 247
232 entity.ProductCode = code; 248 entity.ProductCode = code;
@@ -267,6 +283,25 @@ public class ProductAppService : ApplicationService, IProductAppService @@ -267,6 +283,25 @@ public class ProductAppService : ApplicationService, IProductAppService
267 } 283 }
268 284
269 /// <summary> 285 /// <summary>
  286 + /// 生成未删除数据中不重复的 <c>PRD_</c> 前缀产品编码。
  287 + /// </summary>
  288 + private async Task<string> GenerateUniqueProductCodeAsync()
  289 + {
  290 + for (var i = 0; i < 8; i++)
  291 + {
  292 + var code = $"PRD_{_guidGenerator.Create():N}";
  293 + var exists = await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  294 + .AnyAsync(x => !x.IsDeleted && x.ProductCode == code);
  295 + if (!exists)
  296 + {
  297 + return code;
  298 + }
  299 + }
  300 +
  301 + throw new UserFriendlyException("无法生成唯一产品编码,请稍后重试或手动填写产品编码");
  302 + }
  303 +
  304 + /// <summary>
270 /// 去重、校验门店 Id 格式与存在性。 305 /// 去重、校验门店 Id 格式与存在性。
271 /// </summary> 306 /// </summary>
272 private async Task<List<string>> NormalizeAndValidateLocationIdsAsync(IEnumerable<string> rawIds) 307 private async Task<List<string>> NormalizeAndValidateLocationIdsAsync(IEnumerable<string> rawIds)
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
@@ -52,7 +52,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ @@ -52,7 +52,7 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
52 /// <remarks> 52 /// <remarks>
53 /// L1 标签分类 fl_label_category(含 buttonAppearance;COLOR/IMAGE 展示值在 categoryPhotoUrl);仅对当前门店可用:ALL 或 SPECIFIED 且在 fl_label_category_location; 53 /// L1 标签分类 fl_label_category(含 buttonAppearance;COLOR/IMAGE 展示值在 categoryPhotoUrl);仅对当前门店可用:ALL 或 SPECIFIED 且在 fl_label_category_location;
54 /// L2 产品分类 fl_product.CategoryId join fl_product_category(同上,展示值在 categoryPhotoUrl); 54 /// L2 产品分类 fl_product.CategoryId join fl_product_category(同上,展示值在 categoryPhotoUrl);
55 - /// L3 产品;L4 与该门店、该标签分类、该产品关联的标签实例(fl_label + fl_label_type)。 55 + /// L3 产品卡片:按「产品 + 标签模板」拆分(同一 productId、不同 fl_label.TemplateId 为多张卡);L4 为该卡下与门店、标签分类、该产品、该模板关联的标签实例(fl_label + fl_label_type)。
56 /// L2 仅包含对当前门店可用的类别:AvailabilityType=ALL,或 SPECIFIED 且在 fl_product_category_location 存在该门店记录; 56 /// L2 仅包含对当前门店可用的类别:AvailabilityType=ALL,或 SPECIFIED 且在 fl_product_category_location 存在该门店记录;
57 /// 未归类或分类行未关联到 fl_product_category 时仍归入「无」节点。 57 /// 未归类或分类行未关联到 fl_product_category 时仍归入「无」节点。
58 /// </remarks> 58 /// </remarks>
@@ -102,7 +102,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ @@ -102,7 +102,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
102 LabelTypeId = t.Id, 102 LabelTypeId = t.Id,
103 TypeName = t.TypeName, 103 TypeName = t.TypeName,
104 TypeOrderNum = t.OrderNum, 104 TypeOrderNum = t.OrderNum,
105 - LabelCode = l.LabelCode, 105 + LabelCode = l.LabelCode ?? string.Empty,
  106 + TemplateId = tpl.Id,
106 TemplateCode = tpl.TemplateCode, 107 TemplateCode = tpl.TemplateCode,
107 TemplateWidth = tpl.Width, 108 TemplateWidth = tpl.Width,
108 TemplateHeight = tpl.Height, 109 TemplateHeight = tpl.Height,
@@ -175,7 +176,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ @@ -175,7 +176,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
175 176
176 foreach (var g2 in byL2) 177 foreach (var g2 in byL2)
177 { 178 {
178 - var productsGrouped = g2.GroupBy(x => x.ProductId).OrderBy(pg => pg.First().ProductName); 179 + var productsGrouped = g2
  180 + .GroupBy(x => new { x.ProductId, x.TemplateId })
  181 + .OrderBy(pg => pg.First().ProductName)
  182 + .ThenBy(pg => pg.Key.TemplateId);
179 var appearance = string.IsNullOrWhiteSpace(g2.Key.ButtonAppearance) 183 var appearance = string.IsNullOrWhiteSpace(g2.Key.ButtonAppearance)
180 ? "TEXT" 184 ? "TEXT"
181 : g2.Key.ButtonAppearance.Trim(); 185 : g2.Key.ButtonAppearance.Trim();
@@ -208,10 +212,17 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ @@ -208,10 +212,17 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
208 var subtitle = string.IsNullOrWhiteSpace(first.ProductCode?.Trim()) 212 var subtitle = string.IsNullOrWhiteSpace(first.ProductCode?.Trim())
209 ? "无" 213 ? "无"
210 : first.ProductCode!.Trim(); 214 : first.ProductCode!.Trim();
  215 + var templateLabelSizeText = FormatLabelSize(
  216 + first.TemplateWidth,
  217 + first.TemplateHeight,
  218 + first.TemplateUnit);
211 219
212 l2.Products.Add(new UsAppLabelingProductNodeDto 220 l2.Products.Add(new UsAppLabelingProductNodeDto
213 { 221 {
214 ProductId = first.ProductId, 222 ProductId = first.ProductId,
  223 + TemplateId = first.TemplateId,
  224 + TemplateCode = first.TemplateCode,
  225 + TemplateLabelSizeText = templateLabelSizeText,
215 ProductName = first.ProductName ?? string.Empty, 226 ProductName = first.ProductName ?? string.Empty,
216 ProductCode = first.ProductCode ?? string.Empty, 227 ProductCode = first.ProductCode ?? string.Empty,
217 ProductImageUrl = first.ProductImageUrl, 228 ProductImageUrl = first.ProductImageUrl,
@@ -957,6 +968,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ @@ -957,6 +968,8 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
957 968
958 public string LabelCode { get; set; } = string.Empty; 969 public string LabelCode { get; set; } = string.Empty;
959 970
  971 + public string TemplateId { get; set; } = string.Empty;
  972 +
960 public string? TemplateCode { get; set; } 973 public string? TemplateCode { get; set; }
961 974
962 public decimal TemplateWidth { get; set; } 975 public decimal TemplateWidth { get; set; }
项目相关文档/报表Reports接口对接说明.md
@@ -8,8 +8,8 @@ @@ -8,8 +8,8 @@
8 8
9 ## 0. 角色与数据范围(必读) 9 ## 0. 角色与数据范围(必读)
10 10
11 -- 判断依据:`CurrentUser.Roles` 中是否存在**忽略大小写**等于 **`admin`** 的角色码(与 JWT 中角色码一致,参见 `AuthSessionAppService` / `ReportsRoleHelper`)。  
12 -- **`admin`**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。 11 +- 判断依据(`ReportsRoleHelper.IsAdminRole`,满足其一即可):① `UserName` 为内置 **`admin`**;② JWT 自定义 claim **`Roles`** 中含 **`admin`**(内置超管走 `AccountManager.UserInfoToClaim`,不写标准 `role` claim);③ 任一 **`Permission`** claim 为 **`\*:\*:\*`**;④ 标准 **`CurrentUser.Roles`** 中含忽略大小写的 **`admin`**(普通账号绑定 `RoleCode=admin` 时走此路径)。
  12 +- **管理员(上述任一)**:**不按** `CreatedBy` 过滤,可查看/统计全部 `fl_label_print_task`(仍受 Partner/Group/Location/日期/关键字筛选)。
13 - **非 `admin`**:所有列表与统计仅包含 **`CreatedBy == 当前用户 Id`** 的打印任务。 13 - **非 `admin`**:所有列表与统计仅包含 **`CreatedBy == 当前用户 Id`** 的打印任务。
14 - **重打**:非 admin 仅能重打本人任务;**`admin` 可重打任意用户任务**,但仍须 `locationId` 与历史任务一致(与 App 重打规则一致)。 14 - **重打**:非 admin 仅能重打本人任务;**`admin` 可重打任意用户任务**,但仍须 `locationId` 与历史任务一致(与 App 重打规则一致)。
15 15
项目相关文档/标签模块接口对接说明.md
@@ -582,7 +582,7 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -582,7 +582,7 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
582 582
583 方法:`POST /api/app/product` 583 方法:`POST /api/app/product`
584 584
585 -入参(Body:`ProductCreateInputVo`) 585 +入参(Body:`ProductCreateInputVo`)示例 1(自填编码)
586 ```json 586 ```json
587 { 587 {
588 "productCode": "PRD_TEST_001", 588 "productCode": "PRD_TEST_001",
@@ -597,10 +597,22 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -597,10 +597,22 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
597 } 597 }
598 ``` 598 ```
599 599
  600 +示例 2(不传或 `null` 产品编码时,由后端生成唯一值,形如 `PRD_` + 32 位十六进制):
  601 +```json
  602 +{
  603 + "productCode": null,
  604 + "productName": "Chicken",
  605 + "categoryId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
  606 + "productImageUrl": null,
  607 + "state": true,
  608 + "locationIds": []
  609 +}
  610 +```
  611 +
600 字段说明: 612 字段说明:
601 | 字段 | 类型 | 必填 | 说明 | 613 | 字段 | 类型 | 必填 | 说明 |
602 |------|------|------|------| 614 |------|------|------|------|
603 -| `productCode` | string | 是 | 产品编码 | 615 +| `productCode` | string \| null | **否** | **可选。** 有非空值时按该编码落库,且须全局唯一(未删除数据);不传、`null` 或空串时由后端 **自动生成**唯一 `productCode`。 |
604 | `productName` | string | 是 | 产品名称 | 616 | `productName` | string | 是 | 产品名称 |
605 | `categoryId` | string \| null | 否 | 产品分类 Id(`fl_product_category.id`) | 617 | `categoryId` | string \| null | 否 | 产品分类 Id(`fl_product_category.id`) |
606 | `productImageUrl` | string \| null | 否 | 主图 URL | 618 | `productImageUrl` | string \| null | 否 | 主图 URL |
@@ -608,8 +620,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -608,8 +620,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
608 | **`locationIds`** | `string[]` \| **省略** | 否 | **可选。** 有该字段时:在同一事务内按列表批量写入 **`fl_location_product`**(**每个门店 Id 一行**,即「一产品一门店一条关联」)。**请求体中省略该字段**时:本接口不写门店关联,仍可通过 **§7 Product-Location** 维护。传空数组 `[]` 表示新建产品后不绑定任何门店。 | 620 | **`locationIds`** | `string[]` \| **省略** | 否 | **可选。** 有该字段时:在同一事务内按列表批量写入 **`fl_location_product`**(**每个门店 Id 一行**,即「一产品一门店一条关联」)。**请求体中省略该字段**时:本接口不写门店关联,仍可通过 **§7 Product-Location** 维护。传空数组 `[]` 表示新建产品后不绑定任何门店。 |
609 621
610 校验: 622 校验:
611 -- `productCode` / `productName` 不能为空  
612 -- `productCode` 不能与未删除的数据重复 623 +- `productName` 不能为空
  624 +- 若请求中 **`productCode` 有非空值**:不能与**其它**未删除产品的 `productCode` 重复
613 - 若传入 **`locationIds`** 且含非空项:每个 Id 须为合法 Guid,且对应门店存在于 **`Location`** 主数据且未删除;否则返回友好错误(如「门店Id格式不正确」「门店不存在」) 625 - 若传入 **`locationIds`** 且含非空项:每个 Id 须为合法 Guid,且对应门店存在于 **`Location`** 主数据且未删除;否则返回友好错误(如「门店Id格式不正确」「门店不存在」)
614 626
615 ### 6.4 编辑产品 627 ### 6.4 编辑产品
@@ -620,6 +632,9 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -620,6 +632,9 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
620 - Path:`id` 为当前产品Id(`fl_product.Id`) 632 - Path:`id` 为当前产品Id(`fl_product.Id`)
621 - Body:字段同新增(`ProductUpdateInputVo`,继承 `ProductCreateInputVo`) 633 - Body:字段同新增(`ProductUpdateInputVo`,继承 `ProductCreateInputVo`)
622 634
  635 +**`productCode` 行为:**
  636 +- 不传、`null` 或空串:**保留**原产品的 `productCode`;若原数据异常为空,则按新增规则自动生成唯一编码。
  637 +
623 **`locationIds` 行为(与新增不同,请注意):** 638 **`locationIds` 行为(与新增不同,请注意):**
624 - **请求体中省略 `locationIds` 属性**:不修改 **`fl_location_product`**(仅更新 `fl_product` 主表字段;兼容原「先 PUT 产品再调 §7 同步门店」的调用方式)。 639 - **请求体中省略 `locationIds` 属性**:不修改 **`fl_location_product`**(仅更新 `fl_product` 主表字段;兼容原「先 PUT 产品再调 §7 同步门店」的调用方式)。
625 - **请求体中包含 `locationIds` 属性**(含空数组 `[]`):对该产品的门店关联做 **整表替换**——先删除本产品下全部 **`fl_location_product`** 行,再按列表逐条插入;`[]` 表示解除该产品与所有门店的关联。 640 - **请求体中包含 `locationIds` 属性**(含空数组 `[]`):对该产品的门店关联做 **整表替换**——先删除本产品下全部 **`fl_location_product`** 行,再按列表逐条插入;`[]` 表示解除该产品与所有门店的关联。
@@ -734,7 +749,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -734,7 +749,8 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
734 - **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。 749 - **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。
735 - **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。 750 - **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。
736 - **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。 751 - **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。
737 -- **第四级去重**:同一产品在同一标签分类、同一门店下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。 752 +- **第三级拆卡**:同一 `productId` 若同时存在多套 **`fl_label.TemplateId`**(不同标签模板),在 **`products`** 中拆成 **多条 L3**(`productId` 可相同,以 **`templateId`** 区分);`itemCount` 为卡片条数。
  753 +- **第四级去重**:在同一 L3 卡片(同一产品、同一模板)下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
738 754
739 #### 出参(`List<UsAppLabelCategoryTreeNodeDto>`) 755 #### 出参(`List<UsAppLabelCategoryTreeNodeDto>`)
740 756
@@ -762,14 +778,17 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI @@ -762,14 +778,17 @@ ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductI
762 | `buttonAppearance` | string | **JSON 格式字符串**(与库中一致;空时默认 `"TEXT"`) | 778 | `buttonAppearance` | string | **JSON 格式字符串**(与库中一致;空时默认 `"TEXT"`) |
763 | `availabilityType` | string | `ALL` / `SPECIFIED`(树已按当前门店过滤,仍返回供客户端展示) | 779 | `availabilityType` | string | `ALL` / `SPECIFIED`(树已按当前门店过滤,仍返回供客户端展示) |
764 | `orderNum` | number | 排序 | 780 | `orderNum` | number | 排序 |
765 -| `itemCount` | number | 该分类下 **产品个数**(去重后的产品数) |  
766 -| `products` | array | 第三级产品列表(见下表) | 781 +| `itemCount` | number | 该分类下 **L3 产品卡片条数**(同一产品多模板会多张卡) |
  782 +| `products` | array | 第三级产品卡片列表(见下表) |
767 783
768 -**L3 `UsAppLabelingProductNodeDto`(产品)** 784 +**L3 `UsAppLabelingProductNodeDto`(产品卡片,按模板拆分)**
769 785
770 | 字段 | 类型 | 说明 | 786 | 字段 | 类型 | 说明 |
771 |------|------|------| 787 |------|------|------|
772 | `productId` | string | `fl_product.Id` | 788 | `productId` | string | `fl_product.Id` |
  789 +| `templateId` | string | 当前卡片对应 **`fl_label.TemplateId`**;与 `productId` 组合唯一标识一张卡 |
  790 +| `templateCode` | string \| null | 当前卡片所用模板编码 |
  791 +| `templateLabelSizeText` | string \| null | 当前卡片模板尺寸文案(与四级中该模板尺寸一致) |
773 | `productName` | string | 产品名称 | 792 | `productName` | string | 产品名称 |
774 | `productCode` | string | 产品编码 | 793 | `productCode` | string | 产品编码 |
775 | `productImageUrl` | string \| null | 主图 | 794 | `productImageUrl` | string \| null | 主图 |