material_manage_system/docs/superpowers/plans/2026-10-08-company-master-data-isolation.md
2026-10-09 18:18:58 +08:00

413 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 切换公司后主数据与仓储业务完整隔离:实现计划
> **For agentic workers:** 使用 `superpowers:executing-plans` 按任务执行;实施前确认本文设计。步骤使用复选框追踪。本次仅编写计划,不修改业务代码。
**Goal:** 用户登录后进入已授权的默认公司,切换公司后只能查询和操作该公司的主数据、仓库库位、库存、入出库、申请审批及相关日志,实时推送也按公司隔离。
**Architecture:** 复用 `organizations` 和 `user_organizations`,以经过服务端验证的请求组织上下文贯穿 Handler、Service、Repository。前端公司状态负责选择和页面生命周期;后端授权校验与 SQL 组织条件共同保证隔离。
**Tech Stack:** Go 1.21、Gin、MySQL 8.0、sqlmock、Vue 3、Pinia、Vue Router、Axios、Element Plus、Vitest。
**Spec:** `docs/MMS-用户故事-v2.3.md` 的 ORG-001、编码与主数据、仓库与库存、入出库、申请审批相关故事;本文以下“设计约定”限定本次交付范围。本版完成现有业务的公司隔离,不新增 BOM、导入导出或后台任务功能,也不扩展已有业务状态机。
## 当前代码证据
| 位置 | 当前行为 | 本次补齐 |
| --- | --- | --- |
| `scripts/init.sql` | 已有组织、用户—组织关系表;物料、分类、单位、编码规则已有组织外键和组织内唯一约束 | 使用已有结构,补授权维护、数据检查及增量升级 |
| `internal/handler/admin_master.go` | 从 `X-Organization-ID` 读取组织,缺省为 1,未验证用户授权 | 改为读取可信上下文,移除缺省组织 |
| `internal/repository/material.go` | 创建保存组织;列表、按 ID/编码查询没有组织条件 | 查询、计数、更新、作废、恢复统一限制组织 |
| `internal/service/admin_master.go` | 分类父级、规则分类等引用缺乏完整同组织校验 | 校验引用记录归属及适用状态 |
| `internal/repository/material_log.go` | 日志列表没有组织过滤 | 经物料归属限制日志范围 |
| `internal/repository/stock.go` | 库存读写包含全局查询和 `DEFAULT` 公司、仓库、`MAIN` 库位逻辑 | 显式传入当前公司的仓库、库位维度 |
| `internal/repository/inbound.go`、`internal/repository/outbound.go` | 单据查询未按公司过滤,模型未完整携带库存维度 | 单据和库存读写共同限制公司范围 |
| `internal/repository/application.go` | 申请表没有公司字段,审批自动出库使用默认库存逻辑 | 申请归属、审批和自动出库使用同一公司 |
| `internal/handler/websocket.go` | 对全部连接广播业务数据 | 认证订阅及消息投递按公司隔离 |
| `frontend/src/api/index.js` | 只附加登录令牌 | 给组织级请求附加公司上下文并处理过期响应 |
| `frontend/src/components/layout/AppHeader.vue` | 无公司切换器 | 增加当前公司选择 |
| `frontend/src/views/Users.vue` | 可维护角色,尚未维护组织授权 | 增加可访问公司和默认公司配置 |
## 设计约定
### 1. 公司和授权
- “公司主体”在本次指本方经营组织 `organizations`,不使用供应商、客户的 `business_partners`。
- 每个普通用户可以访问多个公司,但只能有一个默认公司;默认公司必须在授权列表内且处于启用状态。
- 超级管理员自动拥有全部启用公司的访问权限;查询和操作主数据、库存及单据仍必须明确选择一个公司,没有隐式跨公司汇总模式。
- 本次沿用已有功能角色规则,不新增公司级角色体系。组织授权只是数据范围,不会自动授予维护功能的权限。
- 公司无配置、授权被撤销或公司停用时,刷新可访问公司列表并要求重新选择;没有可访问公司时显示空状态,禁止组织级请求。
- 登录首次选择有效默认公司;若默认公司失效但仍有其他授权公司,要求选择。刷新时可恢复当前用户本标签页上次选择,但必须重新验证授权。
### 2. 请求边界
- 所有纳入隔离的接口必须发送正整数 `X-Organization-ID`;缺失或格式错误返回 400。
- 请求顺序:登录认证 → 查询当前用户有效角色及组织授权 → 验证公司启用状态 → 写入 Gin 上下文 → 功能权限校验 → 业务处理。
- 超级管理员身份以数据库有效角色为准,不能单靠 JWT 中可能过期的角色快照跳过组织授权。授权撤销或角色撤销在下一次请求生效。
- 无权访问、公司不存在或停用均返回 403“当前公司不可访问”;同一已授权公司上下文内访问其他公司的记录返回 404。
- 新建主数据、入出库单据或申请时,由后端根据当前选择且已通过授权校验的公司确定数据所属公司,忽略请求体伪造的 `organization_id`。
- 普通编辑不允许更改所属公司;本期不提供跨公司迁移功能。这不是永久禁止调整,后续如需迁移,应另行设计管理员专用流程,检查目标公司的编码冲突、分类、单位以及关联库存、单据和 BOM,并记录审计。
- 查询、分页总数、搜索、编码重复检查、分类筛选、状态变更及日志查询全部带组织条件。SQL 写入也带组织条件,不能只在写入前查一次归属。
- 分类父级、物料分类、基本单位、编码规则分类必须属于当前公司;跨公司引用返回 400,失败时不得改变流水号或数据。
- 不新增物料类型、单位换算、BOM、批量导入导出功能;以后接入这些功能时必须使用同一组织上下文。
### 3. 切换行为
- 公司切换器位于页面左上区域,清楚显示当前公司。切换不修改用户的默认公司授权设置。
- 编辑中切换提示“切换公司将放弃未保存内容”;用户取消则保持当前公司。
- 保存请求执行期间禁止切换,避免写请求完成归属不明确。
- 切换成功后取消旧公司的读请求,清空列表、详情、筛选、分页、选中项、弹窗及业务缓存,再重新加载当前页面。
- 对无法及时取消的请求以组织 ID 和上下文版本双重校验,旧响应和旧错误提示不得影响新页面。A→B→A 时,仅比较公司 ID 不足以识别第一次 A 的响应。
- 深链接指向旧公司的物料、入出库单据或申请时,切换后回到对应列表;不自动用旧记录 ID 请求另一公司的详情。
- 登出和账号变化清空公司状态。使用 `sessionStorage` 保存当前公司,避免不同标签页共用选择造成误切换。
### 3.1 本期每个公司一个仓库、每个仓库一个库位
- 本期每个公司最多配置一个仓库,每个仓库最多配置一个库位;不提供仓库或库位切换功能。仓库和库位均配置并启用后,自动使用这一组仓库、库位。
- 新公司尚未配置仓库时允许维护物料主数据,仓库区域显示“请先配置本公司仓库”,不自动借用其他公司的仓库。
- 切换公司后,清空原公司的仓库、库位及相关缓存,重新加载目标公司的仓库;有且仅有一个启用仓库时自动设置为当前仓库。
- 唯一仓库停用时不设置当前仓库,提示“本公司仓库已停用”。停用后仍占用该公司的唯一仓库名额,可修改、重新启用原仓库,不另建第二个仓库。
- 页面显示当前仓库、库位名称,不增加仓库或库位切换器。切换公司时先清空旧库位 ID,再加载目标仓库的唯一库位;仓库和库位均启用时自动设置当前库位,不按名称推测或沿用旧库位 ID。
- 未配置库位或库位停用时,当前库位为空,分别提示“请先配置本公司仓库的库位”或“本公司库位已停用”。停用库位仍占用唯一名额,可编辑、重新启用原库位,不另建第二个;这些状态不妨碍物料主数据维护。
- 后端以仓库所属公司校验请求中的仓库和库位 ID;自动选择只负责页面状态,不能代替授权和归属校验。
- 本版库存、入出库及审批自动出库均使用当前公司的唯一仓库、库位。后端根据公司解析库存维度,不依赖前端自动选择,不使用历史默认公司的仓库、库位兜底。
- 数据库增加 `warehouses(organization_id)` 和 `warehouse_locations(warehouse_id)` 唯一约束,防止并发创建第二个仓库或库位;升级前发现一个公司已有多个仓库或一个仓库已有多个库位时,阻断升级并列出清单,由业务确认处理,不能自动删除或合并历史仓库、库位。
### 4. 本版仓储业务隔离
本版同时完成库存、入库、出库、申请、审批和实时推送隔离。所有已授权且启用的公司都能使用现有业务功能,不设置仅历史默认公司可用的限制;功能角色权限仍单独校验。
- 库存列表、预警、详情、流水、分页总数、按物料查询只包含当前公司;关联查询同时校验物料、仓库及库位归属。
- 入出库单据及申请保存公司归属。入出库单据保存唯一仓库、库位;单据明细物料必须属于同一公司。更新、删除及库存回退只能操作该公司原单据及原库存维度。
- 新建申请时锁定所属公司;我的申请列表按“当前公司 + 当前用户”查询,审批列表按当前公司查询,审批人需要该公司授权及已有审批功能权限。
- 审批、驳回、取消均检查申请归属和状态。自动出库在同一事务内锁定本公司申请、扣减本公司库存、建立本公司出库单及流水并修改申请状态,不跨公司取库存,不因切换页面而改变申请归属。
- 所有库存读写显式携带公司、仓库、库位,删除 `DEFAULT`/`MAIN` 和 ID 1 的运行时兜底;这些编码只可用于核验已确认的历史数据,不参与新业务路由。
- 未配置或停用仓库、库位时可以查询本公司历史单据和库存;无余额时返回空列表。新建/更新/删除入出库以及自动出库审批等改变库存的操作返回 409 和具体配置提示;不扣库存的申请、驳回、取消及普通审批沿用已有规则,只要求公司授权和相关物料合法。
- 本期不提供单独的仓库授权设置;公司授权涵盖该公司的唯一仓库、库位,普通用户仍只获得其已有角色允许的业务操作。
- WebSocket 连接必须验证身份及公司授权,消息仅发给该公司仍有权限的订阅者。切换公司关闭旧连接后重新订阅;旧连接和延迟消息不得影响新公司页面。
- 已存在的导入导出、统计或后台调用入口如被代码检查发现,必须同步传递组织上下文并纳入验收;不存在的功能不新增,也不留运行时全局绕过入口。
### 5. 数据升级与审计
- 保留历史物料及组织归属,不按公司名称猜测或自动拆分数据,不自动复制默认公司的分类、单位和规则。
- 新公司默认主数据为空,由管理员配置分类、单位和规则后建档物料。
- 现有普通用户的公司授权先生成清单;经业务负责人确认后再执行显式授权 SQL。不得给所有用户自动授权所有公司。
- 新增审计记录组织切换和授权变更;普通用户不能读取其他公司审计内容。审计保存失败时切换校验或授权修改失败。
- 使用单独增量 SQL 更新现有库;`scripts/init.sql` 仅用于空库,不能对现有生产库重跑。
## 实施任务
依赖顺序:任务 1 → 任务 2 → 任务 3 → 任务 4 → 任务 5A → 任务 5B → 任务 5C → 任务 5D → 任务 6 → 任务 7 → 任务 8。任务 5 拆成四个独立可验证的业务交付;本版必须全部完成后发布。所有接口、函数名称在本计划中均为拟新增或拟调整的目标签名。
### 测试策略(精简版)
- 全部功能、设计约定和完成标准保留;只精简测试组织与重复执行。已完成任务的勾选及验证记录保持原样。
- 同一业务场景选择一个主要测试入口,不在 Handler、Service、Repository 三层重复编写相同用例。优先复用现有测试文件,Files 中列出的测试文件按实际职责选用,不要求逐个新增。
- 授权和请求边界使用表驱动测试;前端集中测试公司 store、请求上下文和一个代表性业务页面。各页面已有测试随接口变化维护,不为每个页面复制整套切换测试。
- 库存隔离、入出库编辑和删除后的库存回退、审批事务及数据库约束统一使用 Task 8 的真实 MySQL 双公司测试集。任务 5A~5C 逐步补充并运行相关场景,不再另建重复的真实数据库测试集。
- 每个任务完成后运行受影响的测试即可,不要求每项新增用例单独执行“先确认失败”。修复具体缺陷时仍先复现;只有变更、失败或未解决问题才需要重复运行。
- 最终运行一次全量 Go/前端测试和构建,并完成一次浏览器验收与升级演练。不可省略:跨公司越权、授权撤销、库存并发/回滚、重复审批、A→B→A 旧响应、WebSocket 串流、历史数据升级。
- 后端任务的定向验证按相关包或现有测试名称运行;前端按实际修改的测试文件运行。命令不要求新测试沿用特定命名,完整交付命令见 Task 8。
### Task 1:组织授权和审计的数据底座
> **分阶段交付约束:** Task 1 完成后不能单独部署或将新结构应用到仍运行旧业务代码的数据库。必须完成 Task 5A~5C 的显式维度写入及后续验收,再协调上线。此处明确原 Task 1 与 Task 5A~5C 重复的 INSERT 工作归属,避免提前实现后续 task。
**Files:**
- 修改:`scripts/init.sql`、`internal/model/organization.go`、`internal/repository/organization.go`。
- 新增:`scripts/upgrades/2026-10-08-company-master-data-isolation.sql`、`scripts/checks/company-master-data-integrity.sql`、`internal/repository/organization_access.go`、`internal/repository/organization_audit.go`。
- 测试:`internal/repository/organization_test.go`、`internal/repository/organization_access_test.go`。
**Interfaces:**
```go
type OrganizationAccess struct {
Organizations []Organization `json:"organizations"`
DefaultOrganizationID *int64 `json:"default_organization_id"`
}
// 位于 OrganizationRepository;返回启用的授权公司,管理员返回全部启用公司。
func (r *OrganizationRepository) ListAccessible(userID int64) (*model.OrganizationAccess, error)
func (r *OrganizationRepository) CanAccess(userID, organizationID int64) (bool, error)
```
- [x] 编写测试:普通用户只得到授权启用公司;已撤销授权不再可访问;停用的管理员角色不能获得全公司权限;超级管理员无需逐公司授权。
- [x] 运行 `go test ./internal/repository -run 'TestOrganizationAccess' -count=1`,确认新增行为尚未实现。
- [x] 实现参数化查询,以 `user_roles JOIN roles` 的有效 `super_admin` 判定管理员;普通用户经 `user_organizations JOIN organizations` 获取范围。
- [x] 增量 SQL 新建 `organization_audit_logs`:`id`、`actor_user_id`、可空的 `organization_id`、`action`、JSON `old_value/new_value`、`created_at`,索引 `(organization_id, created_at)` 和 `(actor_user_id, created_at)`。组织切换的旧值/新值记录公司 ID;授权变更记录前后授权集合及默认公司。
- [x] 约束默认公司唯一:在 `user_organizations` 增加生成列 `default_user_id = CASE WHEN is_default THEN user_id ELSE NULL END` 并建立唯一索引。升级前查询同用户多个默认项,存在异常则先人工修正,不直接覆盖。
- [x] 数据检查 SQL 输出:跨公司分类父级、物料分类和基本单位、规则分类、重复默认授权、无有效授权用户以及无归属日志。异常输出必须作为升级阻断清单。
- [x] 检查每个公司的仓库数量;处理已确认的多仓库异常后,为 `warehouses(organization_id)` 建立唯一索引,空库初始化同步加入该约束。测试同公司第二次创建失败、不同公司各自创建成功,并验证并发创建不能突破约束。
- [x] 检查每个仓库的库位数量;处理已确认的多库位异常后,为 `warehouse_locations(warehouse_id)` 建立唯一索引,空库初始化同步加入该约束。测试同仓库第二次创建失败、不同仓库各自创建成功,并发创建和停用后新增均不能突破约束。
- [x] 为 `material_applications` 增加非空 `organization_id`、外键和索引 `(organization_id, user_id, created_at)`、`(organization_id, status, created_at)`;为 `stock_logs` 增加非空 `organization_id/warehouse_id/location_id` 及外键、索引 `(organization_id, material_id, created_at)`。入出库表的公司、仓库、库位列去掉默认 1。业务 INSERT 显式传入已校验维度的改造分别由 Task 5A(库存流水)、Task 5B(入出库单据)、Task 5C(申请及自动出库)完成;本任务只交付数据底座。
- [x] 升级先加可空列,再根据已核验的明细物料和关联单据回填,最后改为非空。申请明细只属于一个公司才可确定申请归属;跨公司、空明细或库存流水关联缺失时输出异常并阻断升级,由业务确认修正,不能把无法确定归属的记录统一填为公司 1。初始表结构与升级后的结构保持一致。
- [x] 数据检查同时覆盖余额、预警规则、入出库单据、申请和库存流水的物料/公司/仓库/库位一致性;确认历史单据维度与实际库存一致,保存升级前后各公司库存汇总及单据数量作为对账基准。
- [x] 在隔离 MySQL 测试库验证空库初始化、已有库升级及唯一约束;sqlmock 不能替代此验证。增量 SQL 按版本只执行一次,操作记录包含执行版本。
- [x] 测试通过后提交:`feat: add organization access and audit foundation`(`ab6c2d60`)。
### Task 2:用户可访问公司与默认公司配置
**Files:**
- 修改:`internal/model/model.go`、`internal/repository/user.go`、`internal/service/auth.go`、`internal/handler/api.go`、`frontend/src/views/Users.vue`、`frontend/src/stores/users.js`。
- 测试:`internal/repository/user_test.go`、`internal/service/auth_test.go`、`frontend/src/views/Users.test.js`。
**Interfaces:**
```json
{
"organization_ids": [1, 2],
"default_organization_id": 2
}
```
用户新增/修改 DTO 增加上述字段,用户列表和详情返回对应字段。新增普通用户必须提供非空有效公司集合及默认公司;修改时字段省略保留原授权,显式空集合可撤销所有授权,但默认公司必须为空。管理员无需逐项授权,可设置启用公司为默认公司。
- [x] 写测试验证授权集合去重、非法/停用公司、默认公司不在集合中、字段省略、撤销所有授权和事务回滚。
- [x] 在用户保存事务中执行锁定用户行、验证公司、替换组织关系、设置唯一默认项、记录审计;保留原有最后超级管理员保护。用户授权与角色保存失败时一起回滚。
- [x] 用户编辑表单增加公司多选和默认公司单选;默认选项只显示已选择启用公司。编辑时仍展示历史停用授权并标注状态,允许移除。
- [x] 执行授权保存相关 Go 测试及 `npm --prefix frontend test -- src/views/Users.test.js`;确认修改授权不改变无关用户范围。
- [x] 提交:`feat: 配置用户公司授权与默认公司`(`5691e03f`)。
### Task 3:可信组织上下文及接口入口
**Files:**
- 新增:`internal/middleware/organization.go`、`internal/service/organization_access.go`、`internal/handler/organization_context.go`。
- 修改:`internal/handler/api.go`、`internal/handler/admin_master.go`、`internal/middleware/auth.go`。
- 测试:`internal/middleware/organization_test.go`、`internal/handler/organization_context_test.go`、`internal/handler/routes_test.go`。
**Interfaces:**
```go
func OrganizationMiddleware() gin.HandlerFunc
func GetCurrentOrganizationID(c *gin.Context) int64
```
新增接口(均需登录,但不要求超级管理员):
| API | 请求/响应 | 用途 |
| --- | --- | --- |
| `GET /api/auth/organizations` | `OrganizationAccess` | 登录后/刷新时取得最新公司范围,不要求组织头 |
| `POST /api/auth/organization-context` | 请求 `{organization_id, previous_organization_id}`,响应 `{organization_id, organization, warehouse}`,仓库摘要格式见任务 6 | 验证目标公司并记录切换审计,不设置服务器全局“当前公司” |
`previous_organization_id` 仅为客户端报告的审计信息,不参与授予权限。每个业务请求仍独立验证组织头,多标签页不会互相覆盖服务器状态。
- [ ] 新增测试矩阵:无头、非法头 → 400;未授权/不存在/停用公司 → 403;正常公司进入 Handler;改请求头不能越权;角色/授权撤销后下一请求立即失效。
- [ ] 实现中间件;`organizationID(c)` 改为读取上下文,不重新解析头、不返回默认 1。内部 Service 接收到组织 0 时返回参数错误。
- [ ] 为物料、基础配置、编码预览、伙伴关系、仓库配置、物料日志、库存、入库、出库、申请和审批注册中间件;组织管理、用户管理及认证初始化接口保持全局管理边界。WebSocket 的授权订阅在任务 5D 完成。
- [ ] 基础配置读取允许已登录且具有物料访问能力的用户使用,写入继续遵循现有管理员限制,确保普通用户能加载物料表单下拉选项。
- [ ] 执行测试,提交:`feat: enforce authenticated organization context`。
### Task 4:物料、基础配置和日志的完整隔离
**Files:**
- 修改:`internal/handler/api.go`、`internal/handler/admin_master.go`、`internal/service/material.go`、`internal/service/admin_master.go`、`internal/service/material_log.go`、`internal/repository/material.go`、`internal/repository/master_data.go`、`internal/repository/material_log.go`。
- 修改及测试:`internal/repository/warehouse.go`、`internal/repository/warehouse_test.go`、`internal/service/admin_master_test.go`,补齐单公司单仓库限制与错误映射。
- 联动修改:`internal/service/inbound.go`、`internal/service/outbound.go`、`internal/service/application.go` 及相关调用点和测试,使所有物料 Service 调用显式携带公司。
- 测试:上述对应 `*_test.go`;新增 `internal/handler/material_organization_test.go`。
**Interfaces:** 为现有方法增加第一个参数 `organizationID int64`,不保留供 HTTP 使用的无组织重载:
```go
func (s *MaterialService) GetByID(organizationID, id int64) (*model.Material, error)
func (s *MaterialService) List(organizationID int64, search, status string, limit, offset int) ([]model.Material, int64, error)
func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error)
func (s *MaterialService) Obsolete(organizationID, id int64, operator string) error
func (s *MaterialService) Restore(organizationID, id int64, operator string) error
func (s *MaterialService) Exists(organizationID, id int64) (bool, error)
func (s *MaterialService) IsActive(organizationID, id int64) (bool, error)
```
Repo 的 `GetByID/GetByMaterialCode/List/Update/Obsolete/Restore/Delete`、日志的 `List/GetByMaterialID` 同样显式增加组织参数。`Create` 沿用 DTO 的组织字段,但只接受 Handler 写入的有效上下文。未注册的删除能力仍同步调整,不新增物理删除路由。
- [ ] 编写双公司测试:公司 A/B 可有相同物料编码;A 列表总数/搜索/详情只含 A;A 更新、作废、恢复 B 的物料返回 404,数据库和日志均无改变。
- [ ] 增加引用测试:A 使用 B 的分类、单位、分类父级和规则分类均返回 400;伪造请求体归属不能改变落库公司;错误请求不消耗编码流水。
- [ ] 所有读写添加组织条件,例如:
```sql
SELECT id, organization_id, material_code, name
FROM materials WHERE organization_id = ? AND id = ?;
SELECT COUNT(*) FROM materials WHERE organization_id = ? AND status = ?;
UPDATE materials SET status = 'obsolete'
WHERE organization_id = ? AND id = ?;
SELECT ml.* FROM material_logs ml
JOIN materials m ON m.id = ml.material_id
WHERE m.organization_id = ?;
```
- [ ] 分类和单位校验使用 `id = ? AND organization_id = ?`;创建要求启用。更新允许保留原先已停用的引用,改选引用时必须选启用项。父分类另校验层级及循环,避免把归属校验等同于全部业务校验。
- [ ] 仓库创建以数据库唯一约束保证每公司最多一个仓库,将该约束冲突返回 409“本公司已配置仓库,请维护现有仓库”;停用仓库也不能再建第二个。仓库、库位读写继续校验当前公司,不能仅由前端禁止新增。
- [ ] 库位创建以数据库唯一约束保证每仓库最多一个库位,将该约束冲突返回 409“本仓库已配置库位,请维护现有库位”;停用库位也不能再建第二个。跨公司仓库 ID 不得用于创建或修改库位。
- [ ] 作废、恢复过程不再忽略物料读取错误,主记录修改和变更日志在同一事务中完成。当前 DTO 未携带版本号,本次不宣称已实现乐观锁;组织条件不能替代后续版本冲突检测。
- [ ] 日志所有入口必须过滤,包括按物料查看和全量列表;无物料关联的历史日志不向普通公司页面暴露,升级检查列出这些记录。
- [ ] 运行 `rg -n 'GetByID|GetByMaterialCode|\.Exists\(|\.IsActive\(' internal` 检查所有调用点;编译和已有入出库测试必须一起通过,不只调整新测试的 mock。
- [ ] 执行受影响的主数据测试,提交:`feat: isolate master data by organization`。全量回归统一在 Task 8 执行。
### Task 5A:库存维度和库存接口隔离
**Files:**
- 新增:`internal/service/inventory_scope.go`、`internal/service/inventory_scope_test.go`。
- 修改:`internal/model/inventory.go`、`internal/model/model.go`、`internal/repository/warehouse.go`、`internal/repository/stock.go`、`internal/service/stock.go`、`internal/handler/api.go`。
- 测试:`internal/repository/stock_test.go`、`internal/service/stock_test.go`;新增 `internal/handler/stock_organization_test.go`。
**Interfaces:**
```go
// InventoryScope 定义在 internal/model/inventory.go
type InventoryScope struct {
OrganizationID int64
WarehouseID int64
LocationID int64
}
// 由数据库解析公司唯一仓库和库位;requireActive=true 用于改变库存的操作。
func ResolveInventoryScope(organizationID int64, requireActive bool) (model.InventoryScope, error)
func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, scope model.InventoryScope, materialID int64) (decimal.Decimal, error)
func (r *StockRepo) UpdateStockTx(tx *sql.Tx, scope model.InventoryScope, materialID int64, delta decimal.Decimal) error
```
读取列表/流水方法显式接收 `organizationID int64`;精确余额及写入方法接收 `InventoryScope`。仓库或库位缺失时解析器返回定义在 `internal/service/errors.go` 的 `ErrInventoryScopeUnavailable`;Handler 映射写请求为 409,列表查询不调用需要完整维度的解析器,因此仍可返回历史数据或空列表。
- [ ] 在共享双公司 MySQL 测试集中覆盖库存读取与预警:同物料编码但不同 ID/余额,A 的库存列表、总数、预警、详情和库存流水只含 A;传 B 的物料 ID 返回 404;A 的预警调整不改 B。
- [ ] 将 `GetDefaultStock/GetStockForUpdateTx/UpdateStockTx`、预警 upsert 和空余额创建中的默认公司编码改为 scope 参数;所有计数与聚合按公司过滤,关联条件包含物料和仓库组织一致性。
- [ ] 更新 `StockLog` 模型和 INSERT,显式保存三个库存维度;按物料/全量流水及带结存流水均按公司计算,不能先全局计算结存再过滤。
- [ ] 出库锁定 `(organization_id, warehouse_id, location_id, material_id)` 余额行,余额不存在按 0 处理。条件扣减保证余额足够,入库创建余额使用现有维度唯一键;修改及恢复原库存遵循原库存规则,同维度多物料按 ID 排序加锁。
- [ ] 在同一测试集中以表驱动覆盖无仓库、无库位、停用、跨公司仓库/物料;增加一次并发扣减验证,确认不会透支、不会锁错公司,也不会创建默认公司的余额。
- [ ] 运行对应测试,提交:`feat: isolate inventory balances and logs by company`。
### Task 5B:入出库单据修改删除与库存回退隔离
**Files:**
- 修改:`internal/model/model.go`、`internal/repository/inbound.go`、`internal/repository/outbound.go`、`internal/service/inbound.go`、`internal/service/outbound.go`、`internal/handler/api.go`。
- 测试:`internal/service/inbound_test.go`、`internal/service/outbound_test.go`;新增 `internal/repository/inbound_organization_test.go`、`internal/repository/outbound_organization_test.go`、`internal/handler/order_organization_test.go`。
**Interfaces:** InboundOrder/OutboundOrder 增加 `OrganizationID/WarehouseID/LocationID int64` JSON 字段。Service 的 `Create/Update/Delete/GetByID/List/GetByMaterialID` 第一个参数为 `organizationID int64`;Repo 同类读取/修改/删除方法增加组织参数,事务方法保留 `tx *sql.Tx` 为第一个参数、组织或 scope 为第二个参数。
- [ ] 在共享双公司 MySQL 测试集中以表驱动覆盖入出库隔离:A 不能读/改/删 B 单据,按物料历史记录与分页总数也隔离;新建/修改单据引用 B 物料时无单据、余额或流水变化。
- [ ] 新建时解析启用库存 scope,后端填写单据三个维度,忽略前端伪造归属;所有头表 INSERT 显式保存维度。单据编号保留现有全局唯一策略,不额外改为公司内编号。
- [ ] 查询头表加组织条件,明细经已授权头表关联并限制同组织物料;对更新和删除,在事务中锁定当前公司的原单据,按原单据维度恢复原单据造成的库存变化,再处理修改后的同公司物料,失败整体回滚。
- [ ] 库存流水删除同时约束组织、关联类型和单据 ID,不删除其他公司的流水;提交完成后才发布带组织 ID 的库存事件。
- [ ] 扩展同一测试集的单据流程:A 的入库/出库/编辑/删除只改变 A 库存;B 保持不变;库存不足及仓库停用错误不会留下半张单据或孤立流水。
- [ ] 运行对应测试,提交:`feat: isolate inbound and outbound workflows by company`。
### Task 5C:申请、审批及自动出库隔离
**Files:**
- 修改:`internal/model/model.go`、`internal/repository/application.go`、`internal/service/application.go`、`internal/handler/api.go`、`cmd/server/main.go`。
- 新增测试:`internal/repository/application_organization_test.go`、`internal/service/application_organization_test.go`、`internal/handler/application_organization_test.go`。
**Interfaces:** MaterialApplication 增加 `OrganizationID int64`。Service 的 `Create/GetByID/ListByUser/ListAll/Approve/Reject/Cancel/GetMaterialStock` 第一个参数为 `organizationID int64`;Repo 的明细、状态及事务查询/修改同样显式携带组织。申请归属取可信上下文,不取当前用户默认公司。
- [ ] 在共享双公司 MySQL 测试集中覆盖申请流程:同一用户在 A/B 创建的申请各自在对应公司显示;审批列表、详情、驳回、取消全部隔离;有两公司权限也不能在 A 上下文操作 B 的申请。
- [ ] 新建申请验证全部物料属于当前公司;查询明细通过组织化申请头表关联物料,所有申请状态 UPDATE 带公司、ID 和原状态条件。取消继续要求本人申请,审批继续要求已有审批角色权限。
- [ ] 自动出库审批在同一事务内 `SELECT ... FOR UPDATE` 锁定当前公司 pending 申请,解析本公司启用仓库、库位,依次锁定本公司余额;创建带 scope 的出库单和流水,扣减库存,修改申请状态,最后提交。重复审批只允许一次成功,不产生重复出库。
- [ ] 不自动出库的审批也在事务中锁定申请并按本公司库存执行原有库存检查;保留已有业务规则,不新增库存预留功能。所有其他状态变更通过条件更新防止覆盖并发状态。
- [ ] 删除或更新 `InitApplicationTable` 的启动建表逻辑:不允许旧定义在新环境创建缺少组织列的申请表;数据库结构统一由初始化/增量 SQL 管理,服务启动检查所需列,缺失时报明确升级错误。
- [ ] 扩展同一测试集的审批事务场景:A 审批自动出库不影响 B 库存;重复审批只成功一次;跨公司物料、库存不足或中途错误使申请状态、出库单、库存和流水一起回滚。
- [ ] 运行对应测试,提交:`feat: isolate material applications and approval outbound`。
### Task 5D:WebSocket 公司订阅与事件投递隔离
**Files:**
- 修改:`internal/handler/websocket.go`、`internal/handler/api.go`、`cmd/server/main.go`。
- 新增:`internal/service/websocket_ticket.go`、`frontend/src/utils/companySocket.js`。
- 新增测试:`internal/handler/websocket_test.go`、`internal/service/websocket_ticket_test.go`、`frontend/src/utils/companySocket.test.js`。
**Interfaces:**
```go
func (h *Hub) BroadcastStockUpdate(organizationID int64, action string, orderID int64, data interface{})
```
`POST /api/auth/websocket-ticket` 需要 JWT 与组织头,返回 `{ticket, expires_at}`;票据为随机、一次性、30 秒有效,绑定 userID/organizationID 及当前会话标识。浏览器以 `/ws?ticket=...` 建连,不能把长期 JWT 放 URL,也不能仅凭 URL 中公司 ID 订阅。
- [ ] 用票据表驱动测试和一组双公司 Hub 测试覆盖:未认证不能取得票据;票据过期/重放/已撤销会话不能连接;A 订阅者收不到 B 的入出库事件;角色或授权撤销后收不到下一条事件。
- [ ] 握手消费票据并重新验证用户、会话和公司,Client 保存身份与组织;Hub 广播携带组织维度,只选择同公司客户端,发送前重新验证有效授权及会话。校验失败或数据库异常则不投递并关闭连接。
- [ ] Hub 注册、注销及投递统一由其事件循环管理,消除原 `RLock` 内删除客户端的问题;事件带 `organization_id`,仅在成功提交后发送,必要时改为无敏感详情的同公司刷新事件。
- [ ] 前端公司切换/登出关闭旧连接,再为当前公司取得票据重连;消息处理核对组织及捕获的上下文版本,连接回调失效时不更新页面。断线恢复仍重新验证授权。
- [ ] 执行票据/Hub 相关 Go 测试及 `npm --prefix frontend test -- src/utils/companySocket.test.js`,提交:`feat: isolate websocket subscriptions and events by company`。浏览器双公司连接和切换验收统一在 Task 8 执行。
### Task 6:前端公司状态、请求隔离及切换器
**Files:**
- 新增:`frontend/src/stores/organization.js`、`frontend/src/utils/organizationContext.js`、`frontend/src/components/layout/OrganizationSwitcher.vue`。
- 修改:`frontend/src/api/auth.js`、`frontend/src/api/index.js`、`frontend/src/stores/auth.js`、`frontend/src/components/layout/AppHeader.vue`、`frontend/src/App.vue`、`frontend/src/router/index.js`、`frontend/src/components/layout/AppSidebar.vue`。
- 测试:新增同目录 `organization.test.js`、`organizationContext.test.js`、`OrganizationSwitcher.test.js`;修改 `frontend/src/stores/auth.test.js`。
**Interfaces:**
```js
// Pinia organization store
// state: organizations, currentOrganizationId, currentWarehouseId, currentLocationId, contextVersion, switching
// initialize(): Promise<void>,加载授权公司,验证上次选择/默认公司
// switchOrganization(id: number): Promise<void>,校验成功后提交本地状态
// reset(): void,登出/账号切换
// 独立上下文工具,不导入 Pinia 或 Axios,避免 auth -> API -> store 循环
// captureOrganizationContext(): { organizationId: number | null, version: number }
// isCurrentOrganizationContext(snapshot): boolean
```
- [ ] 新增测试:默认公司进入、仅显示授权公司、失效默认要求选择、无公司空状态、切换失败保留旧公司、不同账号不继承选择。
- [ ] 新增竞态测试:A 的延迟响应和错误在切换 B 后不提交、不弹提示;A→B→A 时第一次 A 的响应仍丢弃;保存期间不能切换。
- [ ] 请求拦截器为组织接口写入 `X-Organization-ID` 并捕获版本;认证/用户/组织管理接口使用明确白名单,不要求组织选择。
- [ ] 使用 AbortController 取消旧读请求;统一响应检查组织版本。丢弃旧响应使用可识别的取消错误,页面不得将其显示为失败。
- [ ] 建立组织页路由初始化屏障;公司尚未确定时不挂载业务页面,避免组件 `onMounted` 发出缺少组织头的请求。
- [ ] 切换校验成功后更改状态并增加版本;用公司 ID + 版本作为组织级页面的挂载 key。全局用户、组织、角色管理页不作为公司数据页重置。
- [ ] 清空 `currentWarehouseId` 和 `currentLocationId` 并按目标公司加载仓库、库位,只有唯一仓库启用时设置仓库 ID,且其唯一库位也启用时设置库位 ID。无仓库、仓库停用或请求失败时两者保持为空;库位缺失或停用时仅库位为空,不能回退旧公司的仓库或库位。加载响应必须通过组织版本检查。
- [ ] 组织初始化接口在每项授权公司资料中增加 `warehouse: {id, name, status, location: {id, name, status} | null} | null`,切换校验接口为目标公司返回相同摘要,由服务端查询;公司切换器使用此摘要,无需放开管理员仓库配置接口给普通用户。仓库或库位配置修改后刷新当前摘要。
- [ ] 所有授权启用公司均按用户功能角色显示库存、入出库、申请和审批菜单,不根据是否历史默认公司隐藏功能。未配置启用仓库、库位时,页面显示具体配置提示并禁止改变库存的操作,历史查询和不扣库存操作按已有业务规则开放;后端仍独立校验。
- [ ] 运行前端对应测试,提交:`feat: add company switching and request context`。
### Task 7:页面和缓存生命周期
**Files:**
- 修改:`frontend/src/stores/materials.js`、`frontend/src/stores/logs.js`、`frontend/src/stores/stock.js`、`frontend/src/stores/inbound.js`、`frontend/src/stores/outbound.js`、`frontend/src/stores/application.js`、`frontend/src/stores/approval.js`、`frontend/src/stores/ui.js`。
- 修改:`frontend/src/views/Materials.vue`、`frontend/src/views/master-data/MasterDataSettings.vue`、`frontend/src/views/Logs.vue`、`frontend/src/views/partners/PartnerList.vue`、`frontend/src/views/inventory/WarehouseSettings.vue`。
- 修改:`frontend/src/views/Stock.vue`、`frontend/src/views/Inbound.vue`、`frontend/src/views/Outbound.vue`、`frontend/src/views/Application.vue`、`frontend/src/views/Approval.vue` 及对应测试文件;API 模块为 `frontend/src/api/stock.js`、`frontend/src/api/inbound.js`、`frontend/src/api/outbound.js`、`frontend/src/api/application.js`,全部使用公司上下文。
- 测试:上述 stores 和 views 的现有测试文件。
**Interfaces:** 各组织级 store 提供 `resetForOrganization()`;加载方法在提交状态前校验捕获的 `{organizationId, version}`。页面重新挂载清理局部状态,store reset 清理共享状态,两者都必须执行。
- [ ] 写测试:切换后列表/总数/筛选/分页/详情清空;分类、单位、规则重新取目标公司;编辑窗口关闭;旧详情深链接回列表。
- [ ] 写测试:A→B 先清空 A 仓库、库位,再自动显示并使用 B 的唯一启用仓库、库位;无仓库或仓库停用时两个 ID 为空,无库位或库位停用时库位 ID 为空;非管理员能取得当前公司的仓库、库位摘要;延迟返回的 A 数据不能覆盖 B。
- [ ] 写测试:有脏表单时取消切换不清空数据;确认切换才丢弃;取消请求不显示错误,不让旧 finally 覆盖新请求 loading。
- [ ] 给现有组织级 store 实现 reset,清空缓存数据、查询参数、分页及 loading。组织范围变更时重新计算菜单和当前用户有效功能权限,保留已有角色规则。
- [ ] 库存、入出库、申请、审批页面在切换后重新挂载和加载,清空旧物料选择、单据 ID、申请详情与审批弹窗;不保留旧公司库存合计或申请待审批数量。写请求的仓库/库位维度由后端决定,前端只显示当前公司摘要。
- [ ] 自动化页面测试集中在一个代表性业务页面,覆盖未保存提示、保存期间禁止切换和状态清理;其余库存、单据、申请和审批页面复用公共机制,并在 Task 8 一次浏览器验收中确认各公司数据和实时事件正确。
- [ ] 物料、基础配置、伙伴、仓库页面使用切换后的请求上下文;新增表单无可用分类/规则时给出具体配置入口。沿用手工编码允许场景,不把“无规则”无条件当成建档禁止。
- [ ] 仓库配置页面已有仓库时隐藏新增仓库按钮,已有库位时隐藏新增库位按钮,保留两者编辑、启停;后端仍独立限制第二个仓库或库位创建。页面显示当前公司仓库、库位名称及缺失/停用状态,不增加选择器。
- [ ] 执行公共切换机制、代表性页面及本次修改的已有测试,提交:`fix: reset organization page state on company switch`。
### Task 8:双公司验收、升级演练和交付文档
**Files:**
- 新增:`internal/handler/company_isolation_integration_test.go`、`docs/company-master-data-isolation-acceptance.md`。
- 修改:`docs/api.md`、`docs/数据库表结构与关系说明.md`。
- [x] 在独立 MySQL 测试库创建北京/武汉两个公司、不同分类/单位/规则、同编码物料;准备仅北京、仅武汉、两公司及超级管理员四类账号。
- [x] 汇总并运行任务 4、5A~5C 的双公司场景,使用同一组数据经过真实路由、中间件和 MySQL 验证授权、分页总数、按 ID/编码读取、创建、更新、作废、恢复、日志和外键引用,不另写一套重复用例。测试显式设置 `TEST_DB_DSN`;未配置导致跳过时不能计为验收通过。
- [x] 为两个公司分别配置唯一仓库、库位及同编码物料,执行入库→库存查询→申请→审批自动出库→出库查询完整流程,验证所有记录和扣减属于各自公司;对入出库修改/删除后的库存回退、预警、流水及申请驳回/取消逐项测试。
- [x] 浏览器验收依次切换北京→武汉→北京,检查页面和编码流水独立;模拟慢网、保存中切换、撤销授权、停用公司、刷新及登出换账号。
- [x] 验证两个启用授权公司均可使用所有现有业务模块;无仓库、无库位或停用时库存写操作返回具体 409 配置提示,其他公司的资源 ID 返回 404。没有默认公司专属业务限制。
- [x] 在同一次浏览器验收中验证双公司 WebSocket 并行连接不会互串,撤销公司授权/角色或退出登录后旧连接停止投递;核对共享 MySQL 测试集的并发出库、重复审批和事务故障结果,不重复编写同类测试。
- [x] 武汉真实历史副本及授权回填:用户确认武汉尚无历史数据,本项不适用,无执行对象;合成历史升级、对账及阻断场景已在隔离 MySQL 验证。
- [x] 从项目根目录运行:
```powershell
go test ./... -count=1
npm --prefix frontend test
npm --prefix frontend run build
git diff --check
```
- [x] 文档写明组织头、400/403/404/409、授权与切换接口、仓库库位缺失/停用规则、库存与单据事务维度、申请归属、WebSocket 订阅、数据检查和升级步骤。报告测试通过数、跳过数及真实数据库验证情况。
- [x] 提交:`docs: document company isolation rollout and acceptance`。
> 验收记录:2026-10-09,Go 208 个顶层测试(含子测试 478 项),测试跳过 0;前端 44 文件/286 条通过,构建和完整性 SQL 同步检查通过。浏览器及真实 MySQL 证据见 `docs/company-master-data-isolation-acceptance.md`。
## 发布与回退
1. 先在隔离测试环境完成数据检查和增量升级;历史归属异常、多个默认公司和跨组织关联均先处理。
2. 部署时暂停写入,备份数据库;执行已确认的授权回填,再以一次协调发布更新前后端。要求组织头的后端不能与旧前端长期混用。
3. 发布前确认任务 5A~5D 均通过双公司验收,并核对历史库存、单据及流水对账结果;配置各公司唯一仓库、库位,验证审计、订阅和接口错误反馈后恢复写入。所有已授权公司按功能角色开放现有业务模块。
4. 回退时保留新增表、授权和审计,不删主数据。已向多公司写入后不可直接回退到会全局查询的旧应用;应先暂停业务或修复向前,防止回退重新暴露公司数据。
## 完成标准
- [ ] 一个用户可被授权到一个或多个公司,默认公司唯一且有效。
- [ ] 公司 A 的所有主数据读取、总数、搜索和日志均不含公司 B 数据。
- [ ] 改请求头、请求体或记录 ID 都无法访问或改变未授权公司数据。
- [ ] 每个公司独立使用分类、单位、规则及编码流水,同编码可以在不同公司存在。
- [ ] 每个公司最多一个仓库、每个仓库最多一个库位;切换公司自动使用目标公司的唯一启用仓库和库位,缺失或停用时不沿用旧值。
- [ ] 切换不残留旧公司的列表、弹窗、详情、缓存、响应或错误提示。
- [ ] 授权撤销、角色撤销和公司停用在下一次请求生效。
- [ ] 库存、预警、入出库、申请、审批、相关日志及实时推送全部按公司隔离;所有授权启用公司可使用现有业务,没有“仅历史默认公司开放”的临时限制。
- [ ] 本公司库存增加、扣减及恢复原单据造成的库存变化只影响本公司余额;审批自动出库在同一公司和事务内完成,重复审批及并发出库不会造成重复扣减或库存透支。
- [ ] 组织切换、授权变更可审计;历史数据与原有默认公司业务可追溯。
- [ ] Go、前端测试、构建及真实数据库升级验证均有新鲜执行证据。