49755ef0
李曜臣
6-12代码优化
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
|
# 6-1 代码优化
本文档说明 **2026-06-01** 对美国版 **`auth-session/my-menus`** 中 **`lastUpdated`** 字段的语义与实现变更。
---
## my-menus 的 lastUpdated(系统编辑更新时间)
### 背景
前端(`AuthProvider`)用 `GET /api/app/auth-session/my-menus` 返回的 **`lastUpdated`** 判断菜单/权限缓存是否需要刷新。原先取 **`User.LastModificationTime`**,仅在用户资料变更时变化,**菜单、角色、业务数据** 等任意接口修改后不会更新。
### 目标行为
- **`lastUpdated`** 表示 **系统编辑更新时间**。
- 宿主下任意 **`/api/app`** 的 **写操作**(`POST` / `PUT` / `PATCH` / `DELETE`)且 **HTTP 2xx 成功** 后,刷新全局时间戳(分布式缓存)。
- **`my-menus`** 返回:`max(系统编辑时间戳, User.LastModificationTime)`;若从未有过写操作,则仅回退用户资料时间。
### 接口
| 项 | 说明 |
|----|------|
| 方法 | `GET` |
| 路径 | `/api/app/auth-session/my-menus` |
| 鉴权 | Web 登录 Bearer Token |
| 测试环境示例 | `http://flus-test.3ffoodsafety.com/api/app/auth-session/my-menus` |
**请求示例**
```http
GET /api/app/auth-session/my-menus HTTP/1.1
Host: flus-test.3ffoodsafety.com
Authorization: Bearer {access_token}
```
**响应字段(与本次变更相关)**
| 字段 | 类型 | 说明 |
|------|------|------|
| `lastUpdated` | `DateTime?` | 系统编辑全局时间戳;与任意符合条件的写接口成功联动。与用户 `LastModificationTime` 取 **较晚** 者。 |
| `menus` | 数组 | 可见菜单树 |
| `permissionCodes` | 字符串数组 | 权限码 |
| `role` / `fullName` | 字符串 | 角色展示名、全名 |
**响应片段示例**
```json
{
"user": {
"id": "…",
"userName": "admin"
},
"lastUpdated": "2026-06-01T14:32:10.123",
"menus": [],
"permissionCodes": [],
"role": "Administrator",
"fullName": "Admin"
}
```
### 写操作如何刷新时间戳
实现类:`SystemEditStampGlobalFilter`(注册于 `FoodLabelingApplicationModule`)。
| 条件 | 是否刷新 |
|------|----------|
| 路径以 `/api/app` 开头 | 是(含 RBAC、美国版 `food-labeling-us`、同宿主泰额版等) |
| 方法为 POST / PUT / PATCH / DELETE | 是 |
| 响应状态码 2xx | 是 |
| 路径含 `/login`、`/logout`、`forgot-password`、`/captcha`、`/my-menus`、`get-label-report` | **否**(登录、退出、验证码、纯查询报表等) |
缓存键:`FoodLabeling:SystemEditStamp`(`SystemEditStampCacheHelper`)。
### 验证步骤(建议)
1. 调用 `my-menus`,记录 `lastUpdated` 为 **T0**。
2. 调用任意会改数据的写接口(如新增/修改产品、用户、菜单、选店 `auth-scope/select-location` 等),确认 **2xx**。
3. 再次调用 `my-menus`,`lastUpdated` 应为 **T1**,且 **T1 > T0**(秒级即可)。
4. 仅调用 `get-label-report` 等排除路径时,`lastUpdated` **不应** 变化。
### 涉及文件
| 文件 | 说明 |
|------|------|
| `FoodLabeling.Application/Filters/SystemEditStampGlobalFilter.cs` | 写成功后 Touch 时间戳 |
| `FoodLabeling.Application/Helpers/SystemEditStampCache.cs` | 缓存读写 |
| `FoodLabeling.Application/Services/AuthSessionAppService.cs` | `GetMyMenusAsync` 组装 `lastUpdated` |
| `FoodLabeling.Application.Contracts/.../CurrentUserMenuPermissionsOutputDto.cs` | DTO 注释 |
| `FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs` | 接口 XML 注释 |
### 前端说明
`Food Labeling Management Platform/src/components/auth/AuthProvider.tsx` 已支持从响应根级或 `user` 内读取 `lastUpdated`,**无需改前端**即可使用新语义;建议在本地对比 `lastUpdated` 与上次缓存值,变化时重新拉取菜单。
---
## team-member 列表按 Token 权限过滤
### 背景
`GET /api/app/team-member` 原先仅按 Query 的 `partnerId` / `groupId` / `locationId` 筛选,**未**结合登录 Token 的数据范围,非管理员可能看到其它公司的成员。
### 目标行为
| 登录身份 | 列表可见成员 |
|----------|----------------|
| **系统管理员**(`ReportsRoleHelper.IsAdminRole`:用户名 `admin`、角色码 `admin`、权限 `*:*:*` 等) | **全部** 用户(仍可用 Query 按 Company / Region / Location 收窄) |
| **其它账号** | 仅在 **`userlocation` 至少绑定一家门店**,且该门店属于当前账号 **绑定门店所属公司**(`fl_partner`,见 `PartnerScopeHelper`)下的成员 |
与 **`GET /api/app/partner`** 公司列表、`auth-scope` 级联选店使用的公司范围规则一致。
### 接口
| 项 | 说明 |
|----|------|
| 方法 | `GET` |
| 路径 | `/api/app/team-member` |
| 鉴权 | Web 登录 Bearer Token |
| 测试环境示例 | `http://flus-test.3ffoodsafety.com/api/app/team-member?SkipCount=1&MaxResultCount=10` |
| PDF 导出 | `GET /api/app/team-member/export-team-members-pdf`(**相同** Token 范围 + Query 筛选) |
### Query 参数
| 参数 | 说明 |
|------|------|
| `SkipCount` | 跳过条数(分页) |
| `MaxResultCount` | 每页条数 |
| `Keyword` | 姓名 / 用户名 / 邮箱 / 电话模糊搜索 |
| `RoleId` | 角色 Id |
| `State` | 启用状态 |
| `partnerId` | Company(`fl_partner.Id`);非管理员须在本人可见公司内 |
| `groupId` | Region(`fl_group.Id`) |
| `locationId` | 门店 Id;传则优先于 `partnerId` / `groupId` |
| `Sorting` | 排序(可选) |
**筛选优先级**(Query 内):`locationId` → `groupId` → `partnerId`;均未传时,管理员不限制组织范围,非管理员限定为其可见公司下全部门店。
**命中规则**:成员须在 `userlocation` 中至少绑定一家落在「最终门店集合」内的门店。
### 请求示例
```http
GET /api/app/team-member?SkipCount=1&MaxResultCount=10 HTTP/1.1
Host: flus-test.3ffoodsafety.com
Authorization: Bearer {access_token}
```
非管理员带 Company 筛选(须在本人可见公司内):
```http
GET /api/app/team-member?SkipCount=1&MaxResultCount=10&partnerId={fl_partner.Id}
Authorization: Bearer {partner_admin_token}
```
### 验证步骤(建议)
1. 使用 **系统管理员** Token 调用列表 → 应返回全库成员(或受 Query 筛选)。
2. 使用 **仅绑定 A 公司门店** 的 Partner/公司管理员 Token → 仅返回在 A 公司门店有 `userlocation` 绑定的成员;不应出现仅绑定 B 公司的成员。
3. 非管理员传 **其它公司** 的 `partnerId` → 空列表(非 403)。
4. `export-team-members-pdf` 与列表使用同一套范围,条数应与列表筛选一致。
### 库内核对(非管理员 · 某公司)
```sql
-- 替换 :partnerId 为 fl_partner.Id,:partnerName 为 PartnerName
SELECT DISTINCT u.Id, u.UserName, u.Name
FROM `User` u
INNER JOIN userlocation ul ON ul.UserId = CAST(u.Id AS CHAR) AND ul.IsDeleted = 0
INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0
INNER JOIN fl_partner p ON p.IsDeleted = 0 AND (loc.Partner = p.PartnerName OR loc.Partner = p.Id)
WHERE u.IsDeleted = 0
AND p.Id = :partnerId;
```
### 涉及文件
| 文件 | 说明 |
|------|------|
| `FoodLabeling.Application/Helpers/TeamMemberListScopeHelper.cs` | Token 范围 + Query 交集 |
| `FoodLabeling.Application/Helpers/PartnerScopeHelper.cs` | 公司可见范围(复用) |
| `FoodLabeling.Application/Services/TeamMemberAppService.cs` | `GetListAsync`、PDF 导出 |
| `FoodLabeling.Application.Contracts/.../TeamMemberGetListInputVo.cs` | 入参注释 |
| `FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs` | 接口 XML 注释 |
### 联调注意
| 现象 | 处理 |
|------|------|
| 非管理员列表为空 | 确认当前账号 `userlocation` 是否已绑定门店,且门店 `Partner` 能对应到 `fl_partner` |
| 成员无门店绑定 | 不会出现在非管理员列表中(无 `userlocation` 命中) |
| 与 5-18 文档关系 | 5-18 描述 Query 筛选字段;本节补充 **Token 权限范围**,以本节为准 |
---
## product 下载导入模板 405 修复
### 问题现象
```http
GET|POST http://flus-test.3ffoodsafety.com/api/app/product/download-product-import-template
Authorization: Bearer {token}
```
使用 **POST** 调用时返回 **405 Method Not Allowed**(后端已限定为 **GET**;旧版 Web 用 `authorizedPostBlobDownload` 发 POST)。
### 根因
| 项 | 说明 |
|----|------|
| 后端 | `DownloadProductImportTemplateAsync` 标注 **`[HttpGet]`**(ABP 对 `Download*` 也可能误判动词,需显式路由) |
| 前端(修复前) | `productService.downloadProductImportTemplate` 使用 **POST** 下载 Blob |
| 约定 | 与 `export-products-excel`、Location/批量文档一致:**下载模板 / 导出** 均为 **GET**,无 Body |
### 修复方式
1. **后端** `ProductAppService`:增加显式路径,并 **同时接受 GET 与 POST**(兼容未刷新前端):
- `[HttpGet("product/download-product-import-template")]`
- `[HttpPost("product/download-product-import-template")]`
2. **前端** `productService.ts`:改为 **`authorizedGetBlobDownload`**(GET)。
### 接口说明
| 项 | 说明 |
|----|------|
| 方法 | **`GET`**(推荐);**`POST`**(兼容,无 Body) |
| 路径 | `/api/app/product/download-product-import-template` |
| 鉴权 | Web 登录 Bearer Token |
| 响应 | `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`;文件名来自配置 `FoodLabeling:BatchImport:ProductTemplateFileName`(默认 `Product-Manager-批量导入模板.xlsx`) |
| 配置 | `TemplateDirectory` + `ProductTemplateFileName`(见 `批量导入导出接口说明.md` §3.1) |
### 请求示例
```http
GET /api/app/product/download-product-import-template HTTP/1.1
Host: flus-test.3ffoodsafety.com
Authorization: Bearer {access_token}
```
```bash
curl -X GET "http://flus-test.3ffoodsafety.com/api/app/product/download-product-import-template" \
-H "Authorization: Bearer {token}" \
-o "Product-Manager-template.xlsx"
```
### 验证步骤
1. 浏览器或 Postman 使用 **GET** + Token → 应下载 xlsx(非 405)。
2. 仍用 **POST**(无 Body)→ 部署新后端后亦应成功(兼容)。
3. 若返回业务错误「模板文件不存在」→ 检查服务器 `TemplateDirectory` 下是否存在配置的 xlsx。
### 涉及文件
| 文件 | 说明 |
|------|------|
| `FoodLabeling.Application/Services/ProductAppService.cs` | 显式 GET/POST 路由 |
| `FoodLabeling.Application.Contracts/IServices/IProductAppService.cs` | XML 注释 |
| `Food Labeling Management Platform/src/services/productService.ts` | 改为 GET 下载 |
### 联调注意
| 现象 | 处理 |
|------|------|
| 仍 405 | 确认已部署含本修复的后端;前端须 GET 或等新后端支持 POST |
| 404 | 路径须为 `download-product-import-template`,勿当成 `GET /product/{id}` |
| 与导出区别 | 全量导出为 `GET .../export-products-excel?Keyword=...`;模板下载无 Query |
---
## dashboard/overview 首页统计口径修复
### 问题现象
`GET /api/app/dashboard/overview` 返回的 **Active Users**、**Locations**、**People** 与业务实际不符;公司维度账号可能看到全库用户数/门店数,且**系统 admin** 被计入某公司成员统计。
测试环境示例:`http://flus-test.3ffoodsafety.com/api/app/dashboard/overview`
### 目标行为
| 指标 | 统计口径 |
|------|----------|
| **Locations** | 范围内未删除门店数:管理员=全平台;其它账号=其绑定门店所属公司下全部门店 |
| **People** | 范围内**团队成员**:在 `userlocation` 中至少绑定一家范围内门店的用户(`User.IsDeleted=false`),**排除**系统 admin |
| **Active Users** | 同上,且 `User.State=true` |
| **系统 admin** | 用户名 `admin` 或角色码 `admin` 的账号**不计入** People / Active Users(不归属任何 Company) |
打印类指标(**Labels Printed Today**、**Weekly Print Volume**、**Recent Labels**)与 **Products** 亦按同一门店范围过滤;**Active Templates**、**By Category** 仍为全平台口径(与改前一致)。
### 接口
| 项 | 说明 |
|----|------|
| 方法 | `GET` |
| 路径 | `/api/app/dashboard/overview` |
| 鉴权 | Web 登录 Bearer Token |
| 请求参数 | 无(范围由 Token 解析,与 `team-member` 列表、`partner` 列表一致) |
### 请求示例
```http
GET /api/app/dashboard/overview HTTP/1.1
Host: flus-test.3ffoodsafety.com
Authorization: Bearer {access_token}
```
### 响应字段(本次相关)
| 字段 | 说明 |
|------|------|
| `activeUsers` | 启用状态的范围内团队成员数(不含系统 admin) |
| `locations` | 范围内门店数 |
| `people` | 范围内团队成员总数(含禁用,不含系统 admin) |
| `labelsPrintedToday` | 范围内门店当日打印任务数 |
| `weeklyPrintVolume` | 近 7 天范围内打印量 |
| `recentLabels` | 范围内门店最近 10 条打印记录 |
各指标对象含 `value`、`previousValue`、`changeValue`、`changeRate`(周对比基准:最近 7 天起始日前创建的存量等,见实现)。
### 验证步骤(建议)
1. **系统管理员** Token:Locations / People 为全平台(People 不含 `admin` 账号);与库内 `location`、`userlocation` 汇总大致一致。
2. **仅绑定 A 公司门店** 的账号:三项指标仅反映 A 公司范围;不应等于全库总数。
3. 确认 `admin` 用户不在 People / Active Users 中(即使其有 `userlocation` 绑定)。
4. 无 `userlocation` 的普通用户:不计入 People / Active Users。
### 库内核对(某公司团队成员,不含 admin)
```sql
SELECT COUNT(DISTINCT ul.UserId) AS people_count
FROM userlocation ul
INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0
INNER JOIN fl_partner p ON p.IsDeleted = 0 AND (loc.Partner = p.PartnerName OR loc.Partner = p.Id)
INNER JOIN `User` u ON CAST(u.Id AS CHAR) = ul.UserId AND u.IsDeleted = 0
WHERE ul.IsDeleted = 0
AND p.Id = :partnerId
AND u.UserName <> 'admin'
AND u.Id NOT IN (
SELECT ur.UserId FROM UserRole ur
INNER JOIN Role r ON r.Id = ur.RoleId AND r.IsDeleted = 0 AND r.RoleCode = 'admin'
);
```
### 涉及文件
| 文件 | 说明 |
|------|------|
| `FoodLabeling.Application/Helpers/DashboardScopeHelper.cs` | 范围解析、admin 排除、计数 |
| `FoodLabeling.Application/Helpers/TeamMemberListScopeHelper.cs` | 门店范围(复用) |
| `FoodLabeling.Application/Services/DashboardAppService.cs` | `GetOverviewAsync` |
| `FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs` | XML 注释 |
### 联调注意
| 现象 | 处理 |
|------|------|
| 公司账号统计仍为 0 | 检查该账号 `userlocation` 与门店 `Partner` 能否关联到 `fl_partner` |
| admin 仍出现在 People | 确认用户名/角色码是否为 `admin`;部署须含本修复 |
| 与 `Dashboard统计接口对接说明.md` | 该文档为通用结构说明;**统计口径以本节为准** |
---
## 标签 `/api/app/label` 适用 Region(新增/编辑)
### 背景
标签原先仅通过 `locationId` 绑定单门店,无法显式表达「适用哪些 Region」。现与 **label-type**、**label-multiple-option** 一致,支持 **全选(ALL)**、**单选/多选 Region(SPECIFIED + `regionIds`)**,Region 主键为 **`fl_group.Id`**。
### 存储
| 位置 | 字段 | 说明 |
|------|------|------|
| `fl_label` | `AppliedRegionType` | `ALL` = 全平台 Region;`SPECIFIED` = 按关联表 |
| `fl_label_region` | `LabelId` + `GroupId` | 多选 Region 明细(`GroupId` = `fl_group.Id`) |
| `fl_label` | `LocationId` | 仍保留:App 打印/树展示兼容;多 Region 且未显式传门店时,取合并门店列表**第一家** |
**DDL(测试/生产须先执行)**:`module/food-labeling-us/scripts/fl_label_region_scope.sql`
### 接口一览
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/app/label?SkipCount=&MaxResultCount=` | 分页列表;支持 `GroupId` 按 Region 筛选;出参含 `region`、`regionIds`、`appliedRegionType` |
| `GET` | `/api/app/label/{id}` | 详情(`id` = `LabelCode`) |
| `POST` | `/api/app/label` | 新增 |
| `PUT` | `/api/app/label/{id}` | 编辑 |
测试环境示例列表:`http://flus-test.3ffoodsafety.com/api/app/label?SkipCount=1&MaxResultCount=10`
### 请求 Body(新增 / 编辑,JSON)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `appliedRegionType` | string | 否 | `ALL` 或 `SPECIFIED`。传了非空 `regionIds`/`groupIds` 时按 **SPECIFIED** 落库 |
| `regionIds` | string[] | 条件 | Region Id 数组(`fl_group.Id`);与 `groupIds` **合并去重** |
| `groupIds` | string[] | 否 | 与 `regionIds` 等价别名 |
| `locationId` | string | 条件 | 门店 Id;SPECIFIED 时用于校验是否在 Region 范围内;ALL 时可单独指定 |
| `locationIds` | string[] | 否 | 门店候选多选(与 Region 合并解析) |
| `labelName` | string | 是 | 标签名称 |
| `templateCode` | string | 是 | 模板编码 |
| `labelCategoryId` | string | 是 | 标签类别 |
| `labelTypeId` | string | 是 | 标签类型 |
| `productIds` | string[] | 是 | 至少 1 个产品 Id |
| `labelInfoJson` | object | 否 | 模板填写数据 |
| `state` | bool | 否 | 默认 `true` |
### 三种适用范围示例
**1. 全选 Region(ALL)**
```json
{
"labelName": "Demo Label",
"templateCode": "TPL001",
"appliedRegionType": "ALL",
"regionIds": [],
"labelCategoryId": "...",
"labelTypeId": "...",
"productIds": ["..."],
"locationId": "可选-指定展示/打印默认门店"
}
```
**2. 单选 Region**
```json
{
"appliedRegionType": "SPECIFIED",
"regionIds": ["1234567890123456789"],
"locationId": "门店Id-须在Region内",
"labelName": "...",
"templateCode": "...",
"labelCategoryId": "...",
"labelTypeId": "...",
"productIds": ["..."]
}
```
**3. 多选 Region**
```json
{
"regionIds": ["regionId1", "regionId2"],
"groupIds": ["regionId2"],
"labelName": "...",
"templateCode": "...",
"labelCategoryId": "...",
"labelTypeId": "...",
"productIds": ["..."]
}
```
> 未传 `locationId` 且合并 Region 对应多家门店时,后端取**第一家**写入 `fl_label.LocationId`;App 侧 `labeling-tree` / 预览打印通过 Region 关联判断是否适用该门店。
### 响应字段(详情 / 列表相关)
| 字段 | 说明 |
|------|------|
| `appliedRegionType` | `ALL` / `SPECIFIED` |
| `regionIds` | Region Id 数组(ALL 时为空) |
| `groupIds` | 与 `regionIds` 相同 |
| `region` | 展示名:ALL 时为 `All Regions`;SPECIFIED 时为 Region 名称逗号拼接 |
| `locationId` | 当前绑定门店 |
### 列表筛选
| 查询参数 | 说明 |
|----------|------|
| `GroupId` | 按 Region(`fl_group.Id`)过滤:命中 `fl_label_region`,或 `AppliedRegionType=ALL` 的标签 |
| `LocationId` | 按门店(原有) |
| `Keyword` 等 | 与其它筛选组合 |
### 验证步骤(建议)
1. 执行 `fl_label_region_scope.sql` 后部署后端。
2. **POST** 新增:仅 `regionIds` 单选 → 查 `fl_label_region` 一行、`AppliedRegionType=SPECIFIED`。
3. **POST** `appliedRegionType=ALL` → `fl_label_region` 无行、`AppliedRegionType=ALL`。
4. **PUT** 编辑:改为多选 `regionIds` → 关联表行数与 Id 一致(先删后插)。
5. **GET** 列表/详情:出参 `region`、`regionIds` 与库一致。
6. App:`/api/app/us-app-labeling/labeling-tree` 仅展示对当前门店 Region 命中的标签(ALL 或关联 Region)。
### 库内核对 SQL
```sql
-- 标签 Region 范围
SELECT l.Id, l.LabelCode, l.AppliedRegionType, l.LocationId
FROM fl_label l
WHERE l.IsDeleted = 0 AND l.LabelCode = :labelCode;
SELECT lr.GroupId, g.GroupName
FROM fl_label_region lr
INNER JOIN fl_group g ON g.Id = lr.GroupId AND g.IsDeleted = 0
WHERE lr.LabelId = :labelId;
```
### 涉及文件
| 文件 | 说明 |
|------|------|
| `scripts/fl_label_region_scope.sql` | DDL |
| `Helpers/LabelRegionScopeHelper.cs` | 保存/展示/列表筛选/App 校验 |
| `Services/LabelAppService.cs` | CRUD、列表出参 |
| `Services/UsAppLabelingAppService.cs` | App 树与打印范围 |
| `Contracts/Dtos/Label/*` | 入参/出参 |
| `Contracts/IServices/ILabelAppService.cs` | XML 注释 |
| Web `labelService.ts` / `types/label.ts` | 前端 API 映射 |
| Web `LabelsList.tsx` | 创建/编辑提交 `appliedRegionType` + `regionIds` |
### 联调注意
| 现象 | 处理 |
|------|------|
| 保存报「请选择适用 Region」 | SPECIFIED 须传 `regionIds` 或 `locationId` |
| 列表无 `region` 列 | 确认已执行 DDL 并部署含 `LabelRegionScopeHelper` 的版本 |
| 编辑后 Region 未变 | 检查 Body 是否带 `regionIds`;Web 创建/编辑已随 Region 选择器提交 |
|