# 2026-07-24 Team Member 范围 ALL 哨兵(美国版后端) ## 背景 New User / Edit User 弹窗中 Locations、Region 支持勾选 **ALL**。前端可传 `locationIds: ["ALL"]`(或 `regionIds: ["ALL"]`),后端需按当前 Company(`partnerId` / `partnerIds`)展开并落库到 `userlocation`;列表 **Assigned Locations** 覆盖全部门店时展示 **`All Location`**,Region 覆盖全部时展示 **`All Region`**。 本次**仅改美国版后端**,不改 Web 前端。 --- ## 接口 | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/app/team-member` | 新增成员 | | PUT | `/api/app/team-member/{id}` | 更新成员 | 批量编辑 `PUT /api/app/team-member/team-members-bulk` 单行字段与单条 PUT 相同,同样支持 ALL。 --- ## ALL 传参约定 ### 哨兵值 - 字符串 **`ALL`**(大小写不敏感:`ALL` / `all` / `All`) - 适用字段:`locationIds`、`regionIds`、`groupIds`(与 `regionIds` 等价)、`locations`(与 `locationIds` 合并解析) ### 与具体 Guid 同传 若数组中同时含 `ALL` 与具体门店/Region Guid,**以 ALL 为准**(视为全选,忽略具体 Id)。 ### 优先级(Team Member 落库) **不再做 partner + region + location 并集**;按下列顺序取**单一**规则展开: 1. **`locationIds` 含 `ALL`** → 按 `partnerId` / `partnerIds` 展开该公司**全部门店** 2. **有具体 `locationIds`,且 `regionIds` 为空或为 `ALL`** → **只绑这些门店**(Region=ALL 下再收窄到指定门店;避免 `regionIds:["ALL"]` 盖掉单店) 3. **`regionIds` 含 `ALL`**(且无具体门店)→ 按 Company 展开该公司**全部 Region** 下门店 4. **`regionIds` 有具体 Guid** → **按这些 Region 展开门店落库**(**忽略同传的 `locationIds`**,保证多区域能落库回显) 5. **仅有具体 `locationIds`** → 只绑定指定门店 6. **仅有 `partnerId` / `partnerIds`**(Company Admin 等)→ 绑定该公司全部门店 | 请求示例 | 落库规则 | |----------|----------| | `partnerId` + `locationIds: ["ALL"]` | 展开该公司**全部**门店写入 `userlocation` | | `partnerId` + `regionIds: ["ALL"]` + `locationIds: ["{loc}"]` | **只绑 `{loc}`**(Region ALL + 具体门店时门店优先) | | `partnerId` + `regionIds: ["ALL"]`(无 location) | 展开该公司**全部 Region** 下门店 | | `partnerId` + `regionIds: ["{groupGuid1}", "{groupGuid2}"]` | 展开两 Region 下门店并集 | | `partnerId` + `regionIds: ["{g1}","{g2}"]` + `locationIds: ["{loc}"]` | **按 Region 展开**(忽略 `{loc}`),`userlocation` 为两 Region 门店并集;Get 回显 `regionIds` 含 2 个 | | `partnerId` + `locationIds: ["{guid1}", "{guid2}"]`(无 region) | 仅绑定指定门店 | | Company Admin 仅传 `partnerId`(无 region/location) | 仍自动绑定该公司全部门店(原逻辑) | **注意**:`locationIds` / `regionIds` 含 ALL 时须传 **`partnerId` 或 `partnerIds`**,否则返回 400。 ### 编辑回显(GET 折叠为 ALL) 库表 `userlocation` **仍存展开后的门店 Guid**。`GET /api/app/team-member/{id}`(及列表同名字段)在判断为「已覆盖该公司全部」时,将 Id 数组折叠为哨兵,与新增传 ALL 对称: | 条件 | 出参 | |------|------| | 绑定门店 = 该公司全部门店 | `locationIds: ["ALL"]`,`assignedLocations` 展示 All Location | | 反推 Region = 该公司全部 Region | `regionIds` / `groupIds: ["ALL"]` | | 未全选 | 仍返回具体 Guid 数组 | 示例(新增时传 `regionIds/locationIds: ["ALL"]` 后,再 GET): ```json { "partnerIds": ["3a22a48c-9758-9a26-e4ee-390b0f042835"], "regionIds": ["ALL"], "groupIds": ["ALL"], "locationIds": ["ALL"] } ``` --- ## 请求 / 响应示例 ### 1. Locations 全选(ALL) **请求** ```json POST /api/app/team-member { "fullName": "Test All Loc", "userName": "test.all.loc@example.com", "password": "Pass123!", "email": "test.all.loc@example.com", "roleId": "ROLE_GUID", "partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e", "locationIds": ["ALL"], "state": true } ``` **落库**:`userlocation` 写入 Partner `Subway USA` 下全部未删除门店 Id。 **列表/详情 `assignedLocations`**(覆盖全部门店时): ```json "assignedLocations": [ { "locationName": "All Location" } ] ``` ### 2. 仅指定门店 Guid(对比) **请求** ```json "partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e", "locationIds": [ "3a222878-b4c1-d27b-d437-93da14886350", "3a22287c-d353-da47-869f-0bb5dacced51" ] ``` **列表 `assignedLocations`**:展示具体名称,如 `Subway LAX Store`、`Subway UNCC Store`,**不会**出现 `All Location`。 ### 3. Region 全选(ALL) **请求** ```json "partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e", "regionIds": ["ALL"] ``` **落库**:该公司下所有 Region 对应门店写入 `userlocation`。 **Region 展示**:绑定覆盖该公司全部门店时,列表 Region 文案为 **`All Region`**(`FoodLabelingDisplayConsts.AllRegion`)。 ### 4. 编辑多 Region + 同传 location(按 Region 展开) **场景**:UI 勾选 2 个 Region,请求体可能仍带 1 个 `locationIds`(历史选中项)。后端**以 Region 为准**展开,保证多区域能落库并回显。 **请求** ```json PUT /api/app/team-member/{id} { "fullName": "张三", "userName": "123", "email": "123@qq.com", "roleId": "3a21836a-cc04-1783-b054-f2ff3c45d4bf", "partnerId": "3a22a48c-9758-9a26-e4ee-390b0f042835", "regionIds": [ "3a22a48d-98be-7d54-7b9d-6062afb176be", "3a22a48d-5356-8754-d839-a6b7aaad02f9" ], "groupIds": [ "3a22a48d-98be-7d54-7b9d-6062afb176be", "3a22a48d-5356-8754-d839-a6b7aaad02f9" ], "locationIds": ["3a22a48f-4114-ad64-11bf-9adb09f999b9"], "state": true } ``` **落库**:`userlocation` 写入两 Region 下门店并集(**不是**仅 `{locationIds}` 那一店)。 **Get 回显**:`regionIds` 含上述 2 个 Region(由 `userlocation` → 门店 → `fl_group` 反推)。 --- ## 列表展示规则 | 条件 | Assigned Locations | Region(列表/PDF) | |------|-------------------|-------------------| | `userlocation` 覆盖该成员 Company 下**全部门店** | `All Location` | 全部 Region 时 `All Region` | | 仅部分门店 | 门店 Code/Name,分号拼接 | 具体 Region 名称 | 实现位置: - 落库展开:`LocationScopeBindingHelper.ResolveTeamMemberLocationIdsForSaveAsync` - 展示折叠:`TeamMemberScopeDisplayHelper.FormatAssignedLocationsForListAsync` / `FormatRegionTextForListAsync` - 全量回显:`TeamMemberAppService.ApplyCompanyAdminDisplayScopeAsync`(绑定覆盖全部门店时展开 Region) --- ## curl 示例 ### 获取 Token ```bash curl -X POST "http://localhost:19001/api/oauth/Login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "userName=admin&password=123456" ``` 将响应中 `data.token`(已含 `Bearer ` 前缀)用于后续 `Authorization` 头。 ### 新增 — Locations ALL ```bash curl -X POST "http://localhost:19001/api/app/team-member" \ -H "Authorization: " \ -H "Content-Type: application/json" \ -d "{ \"fullName\": \"All Loc User\", \"userName\": \"all.loc.user@example.com\", \"password\": \"Pass123!\", \"email\": \"all.loc.user@example.com\", \"roleId\": \"\", \"partnerId\": \"3a222876-7c49-6f01-c232-2d21dde8f27e\", \"locationIds\": [\"ALL\"], \"state\": true }" ``` ### 更新 — Region ALL ```bash curl -X PUT "http://localhost:19001/api/app/team-member/" \ -H "Authorization: " \ -H "Content-Type: application/json" \ -d "{ \"fullName\": \"All Region User\", \"userName\": \"all.region.user@example.com\", \"roleId\": \"\", \"partnerId\": \"3a222876-7c49-6f01-c232-2d21dde8f27e\", \"regionIds\": [\"ALL\"], \"state\": true }" ``` ### 查询列表验证 Assigned Locations ```bash curl -X GET "http://localhost:19001/api/app/team-member?SkipCount=0&MaxResultCount=20" \ -H "Authorization: " ``` 检查 `items[].assignedLocations` 与 `items[].locationIds`。 --- ## 改动文件 | 文件 | 变更 | |------|------| | `FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs` | ALL 哨兵识别;`ResolveTeamMemberLocationIdsForSaveAsync` 展开逻辑 | | `FoodLabeling.Application/Services/TeamMemberAppService.cs` | 合并 `locations`;Create/Update XML 注释;全量绑定展示 | | `FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs` | 字段注释 + `Locations` | | `FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs` | 字段注释 + `Locations` | | `FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs` | Create/Update 接口说明 | --- ## 验证 - `dotnet build` 编译通过 - 可选:MCP `user-antis-foodlabeling-us-mysql` 查 `userlocation` 确认 ALL 请求后行数等于该公司 `location` 未删除门店数 --- ## label-template 多公司 / 多 Region / 多门店传参与落库(美国版) ### 背景 `POST/PUT /api/app/label-template` 在 **Company / Region / Location** 三维均为 `SPECIFIED` 且各传多个 Id 时,若所选 Id 恰好等于「当前上下文全部可选项」,旧逻辑会把维度折叠为 `ALL` 并**清空关联表**,详情再按门店反推 Company,常表现为 **只回显 1 个公司**。 ### 根因 `AllScopeBindingHelper.ResolveDimensionType` 在 `isFullSelection == true` 时一律返回 `ALL`,**未尊重入参显式 `appliedPartnerType` / `appliedRegionType` / `appliedLocation` = `SPECIFIED`**。 ### 修复约定 | 入参 | 落库行为 | |------|----------| | `appliedPartnerType: "SPECIFIED"` + `partnerIds` / `companyIds` 数组 | **始终**写 `fl_label_template_partner`(每个 Id 一行),主表 `AppliedPartnerType = SPECIFIED` | | `appliedRegionType: "SPECIFIED"` + `regionIds` / `groupIds` | **始终**写 `fl_label_template_region` | | `appliedLocation: "SPECIFIED"` + `locationIds` / `appliedLocationIds` | **始终**写 `fl_label_template_location` | | 各维度 `ALL` 或未显式 `SPECIFIED` 且 Id 覆盖当前上下文全集 | 仍归档为 `ALL`,不写该维度关联快照(后续新增主数据动态适用) | **字段别名(合并去重)** - Company:`partnerIds` ≡ `companyIds`(`fl_partner.Id`) - Region:`regionIds` ≡ `groupIds`(`fl_group.Id`) - Location:`locationIds` ≡ `appliedLocationIds`(`location.Id`) ### 请求示例(2 个 Company + 2 个 Region + 2 个门店) ```json PUT /api/app/label-template/tpl_xxx { "appliedPartnerType": "SPECIFIED", "companyIds": ["{partnerId1}", "{partnerId2}"], "partnerIds": ["{partnerId1}", "{partnerId2}"], "appliedRegionType": "SPECIFIED", "regionIds": ["{groupId1}", "{groupId2}"], "groupIds": ["{groupId1}", "{groupId2}"], "appliedLocation": "SPECIFIED", "locationIds": ["{locId1}", "{locId2}"], "appliedLocationIds": ["{locId1}", "{locId2}"] } ``` ### 验证(MCP / curl) 1. 保存后查库: ```sql SELECT AppliedPartnerType, AppliedRegionType, AppliedLocationType FROM fl_label_template WHERE TemplateCode = 'tpl_xxx'; SELECT PartnerId FROM fl_label_template_partner WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx'); SELECT GroupId FROM fl_label_template_region WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx'); SELECT LocationId FROM fl_label_template_location WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx'); ``` 2. `GET /api/app/label-template/tpl_xxx` 出参 `companyIds` / `partnerIds` / `regionIds` / `locationIds` 数组长度应与入参一致。 ### 改动文件 | 文件 | 变更 | |------|------| | `Helpers/AllScopeBindingHelper.cs` | 显式 `SPECIFIED` 时不因全选折叠为 `ALL` | | `Helpers/LabelTemplateScopeHelper.cs` | 保存传参对齐 Label 实体;详情/列表读取主表 scope 类型,避免门店反推覆盖 | | `Helpers/LabelTemplateScopeSchemaHelper.cs` | 批量读取 `AppliedPartnerType` / `AppliedRegionType` |