feat: 组织列表为每家公司补充仓库摘要

组织初始化接口 GET /api/auth/organizations 现在为每项授权公司附带
warehouse 摘要({id, name, status, location: {id, name, status} | null} | null),
结构与切换接口一致,供普通用户在公司切换器直接展示,无需放开管理员仓库配置接口。

- model:新增 WarehouseSummary/LocationSummary/OrganizationSummary,
  OrganizationAccess 每项改为内嵌公司资料再加 warehouse 摘要。
- repository:新增 WarehouseRepository.Summary 复用切换接口的解析规则;
  ListAccessible 逐公司补齐摘要,仓库或库位配置不唯一时报错而非随意取第一条,
  无仓库、无库位或已停用如实返回,不回退旧公司数据。
- service:Switch 复用 List 已解析的摘要,响应格式保持不变。
- 测试:新增服务层 sqlmock 用例与仓储层 MySQL 集成用例,
  覆盖启用唯一仓库加启用库位、无仓库、仓库停用三类。
This commit is contained in:
文睿 张 2026-10-09 10:43:35 +08:00
parent 838889ce9f
commit 970ba5ccf7
7 changed files with 200 additions and 35 deletions

View File

@ -54,6 +54,9 @@ func TestOrganizationContextListWithoutOrganizationHeader(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectContextAccess(mock, true)
// 组织初始化列表现在会为每家公司附带仓库摘要;该公司无仓库,摘要为 null。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(2)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}))
router := gin.New()
NewHandler().RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/auth/organizations", nil)
@ -63,6 +66,9 @@ func TestOrganizationContextListWithoutOrganizationHeader(t *testing.T) {
if w.Code != 200 || !strings.Contains(w.Body.String(), `"default_organization_id":2`) {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"warehouse":null`) {
t.Fatalf("无仓库公司应返回空仓库摘要 body=%s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}

View File

@ -5,8 +5,31 @@ import (
"time"
)
// LocationSummary 是仓库摘要里的最小库位信息,字段与切换接口一致。
type LocationSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
// WarehouseSummary 是组织初始化接口为每家公司附带的最小仓库摘要,结构与切换
// 接口一致。普通用户据此在公司切换器展示仓库、库位,无需调用管理员仓库配置接口。
type WarehouseSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location *LocationSummary `json:"location"`
}
// OrganizationSummary 是授权公司列表项:公司资料加当前仓库摘要。内嵌
// Organization 让 JSON 中公司字段保持扁平,warehouse 无配置时为 null。
type OrganizationSummary struct {
Organization
Warehouse *WarehouseSummary `json:"warehouse"`
}
type OrganizationAccess struct {
Organizations []Organization `json:"organizations"`
Organizations []OrganizationSummary `json:"organizations"`
DefaultOrganizationID *int64 `json:"default_organization_id"`
}

View File

@ -19,9 +19,9 @@ func (r *OrganizationRepository) ListAccessible(userID int64) (*model.Organizati
return nil, err
}
defer rows.Close()
access := &model.OrganizationAccess{Organizations: make([]model.Organization, 0)}
access := &model.OrganizationAccess{Organizations: make([]model.OrganizationSummary, 0)}
for rows.Next() {
var item model.Organization
var item model.OrganizationSummary
var isDefault bool
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Status, &item.DefaultTimezone, &item.CreatedAt, &item.UpdatedAt, &isDefault); err != nil {
return nil, err
@ -32,7 +32,20 @@ func (r *OrganizationRepository) ListAccessible(userID int64) (*model.Organizati
access.DefaultOrganizationID = &id
}
}
return access, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// 逐公司补齐仓库、库位摘要,供普通用户在公司切换器直接展示。查询复用
// WarehouseRepository;公司或库位配置不唯一时 Summary 会报错,不随意取第一条。
warehouses := NewWarehouseRepository()
for i := range access.Organizations {
summary, err := warehouses.Summary(access.Organizations[i].ID)
if err != nil {
return nil, err
}
access.Organizations[i].Warehouse = summary
}
return access, nil
}
func (r *OrganizationRepository) CanAccess(userID, organizationID int64) (bool, error) {

View File

@ -69,6 +69,51 @@ func TestOrganizationAccessUsesCurrentEnabledRolesAndGrants(t *testing.T) {
}
}
// 组织初始化列表必须为每家公司附带与切换接口一致的仓库、库位摘要。
// 覆盖:启用唯一仓库加启用库位、无仓库、仓库停用三类,均如实返回,不回退旧数据。
func TestListAccessibleIncludesWarehouseSummary(t *testing.T) {
db := isolationDB(t, false)
mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(9,'summary-admin','x');
INSERT INTO user_roles(user_id,role_id) SELECT 9,id FROM roles WHERE code='super_admin';
INSERT INTO organizations(id,code,name,status) VALUES
(2,'TWO','无仓库公司','active'),
(3,'THREE','停用仓库公司','active'),
(4,'FOUR','启用仓库公司','active');
INSERT INTO warehouses(id,organization_id,code,name,status) VALUES
(30,3,'D3','停用仓库','inactive'),
(40,4,'D4','启用仓库','active');
INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES
(31,30,'L3','停用仓库的库位','active'),
(41,40,'L4','启用仓库的库位','active');`)
access, err := NewOrganizationRepository().ListAccessible(9)
if err != nil {
t.Fatal(err)
}
byID := make(map[int64]model.OrganizationSummary, len(access.Organizations))
for _, item := range access.Organizations {
byID[item.ID] = item
}
if len(byID) != 4 {
t.Fatalf("应返回四家授权公司,实际 %+v", access.Organizations)
}
// 启用唯一仓库加启用库位:摘要含仓库与库位,状态如实透传。
enabled := byID[4]
if enabled.Warehouse == nil || enabled.Warehouse.ID != 40 || enabled.Warehouse.Status != "active" ||
enabled.Warehouse.Location == nil || enabled.Warehouse.Location.ID != 41 || enabled.Warehouse.Location.Status != "active" {
t.Fatalf("启用仓库摘要错误 %+v", enabled.Warehouse)
}
// 无仓库不是错误,摘要为 null。
if noWarehouse := byID[2]; noWarehouse.Warehouse != nil {
t.Fatalf("无仓库公司应返回空摘要,实际 %+v", noWarehouse.Warehouse)
}
// 仓库停用不是错误:仍返回该公司自己的仓库,状态为停用,不回退默认公司仓库。
disabled := byID[3]
if disabled.Warehouse == nil || disabled.Warehouse.ID != 30 || disabled.Warehouse.Status != "inactive" ||
disabled.Warehouse.Location == nil || disabled.Warehouse.Location.ID != 31 {
t.Fatalf("停用仓库摘要错误 %+v", disabled.Warehouse)
}
}
// An audit failure must reach the caller so its business mutation can roll back;
// a rollback must remove both records, and reads must require an organization.
func TestOrganizationAuditTransactionAndScope(t *testing.T) {

View File

@ -67,6 +67,33 @@ func (r *WarehouseRepository) List(organizationID int64) ([]model.Warehouse, err
return items, rows.Err()
}
// Summary 返回公司当前唯一仓库及其唯一库位的摘要,供公司切换接口和组织初始化
// 列表共用。一个公司有多个仓库或多个库位属内部不变量错误(warehouses 与
// warehouse_locations 上的唯一约束保证不可达),此时返回错误而不是随意取第一条;
// 无仓库、无库位或已停用都不是错误,按 nil 或原状态如实返回,不回退旧公司数据。
func (r *WarehouseRepository) Summary(organizationID int64) (*model.WarehouseSummary, error) {
warehouses, err := r.List(organizationID)
if err != nil {
return nil, err
}
if len(warehouses) > 1 {
return nil, errors.New("公司仓库配置不唯一")
}
if len(warehouses) == 0 {
return nil, nil
}
warehouse := warehouses[0]
if len(warehouse.Locations) > 1 {
return nil, errors.New("公司库位配置不唯一")
}
summary := &model.WarehouseSummary{ID: warehouse.ID, Name: warehouse.Name, Status: warehouse.Status}
if len(warehouse.Locations) == 1 {
location := warehouse.Locations[0]
summary.Location = &model.LocationSummary{ID: location.ID, Name: location.Name, Status: location.Status}
}
return summary, nil
}
func (r *WarehouseRepository) Create(item *model.Warehouse) error {
result, err := database.DB.Exec(`INSERT INTO warehouses (organization_id, code, name, status) VALUES (?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.Status)
if err != nil {

View File

@ -72,17 +72,10 @@ func (s *OrganizationAccessService) Validate(userID, organizationID int64) error
return nil
}
type LocationContext struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
type WarehouseContext struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location *LocationContext `json:"location"`
}
// 复用 model 中的仓库摘要结构,切换接口与组织初始化列表字段名、结构完全一致。
type LocationContext = model.LocationSummary
type WarehouseContext = model.WarehouseSummary
type OrganizationContext struct {
OrganizationID int64 `json:"organization_id"`
Organization model.Organization `json:"organization"`
@ -95,11 +88,12 @@ func (s *OrganizationAccessService) Switch(userID, organizationID, previousID in
if organizationID <= 0 {
return nil, ErrInvalidOrganizationID
}
// List 已为每家公司解析好仓库摘要,直接复用,避免重复查询。
access, err := s.List(userID)
if err != nil {
return nil, err
}
var target *model.Organization
var target *model.OrganizationSummary
for i := range access.Organizations {
if access.Organizations[i].ID == organizationID {
target = &access.Organizations[i]
@ -109,24 +103,10 @@ func (s *OrganizationAccessService) Switch(userID, organizationID, previousID in
if target == nil {
return nil, ErrOrganizationInaccessible
}
result := &OrganizationContext{OrganizationID: organizationID, Organization: *target}
warehouses, err := repository.NewWarehouseRepository().List(organizationID)
if err != nil {
return nil, err
}
if len(warehouses) > 1 {
return nil, errors.New("公司仓库配置不唯一")
}
if len(warehouses) == 1 {
w := warehouses[0]
if len(w.Locations) > 1 {
return nil, errors.New("公司库位配置不唯一")
}
result.Warehouse = &WarehouseContext{ID: w.ID, Name: w.Name, Status: w.Status}
if len(w.Locations) == 1 {
l := w.Locations[0]
result.Warehouse.Location = &LocationContext{ID: l.ID, Name: l.Name, Status: l.Status}
}
result := &OrganizationContext{
OrganizationID: organizationID,
Organization: target.Organization,
Warehouse: target.Warehouse,
}
oldValue, err := json.Marshal(map[string]int64{"organization_id": previousID})
if err != nil {

View File

@ -0,0 +1,71 @@
package service
import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/pkg/database"
)
func organizationAccessMock(t *testing.T) sqlmock.Sqlmock {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
previous := database.DB
database.DB = db
t.Cleanup(func() { database.DB = previous; db.Close() })
return mock
}
// 组织初始化列表要为每家公司附带与切换接口一致的仓库摘要。这里覆盖
// 无仓库(null)、仓库停用(如实返回停用状态)、启用唯一仓库加启用库位三类。
func TestOrganizationAccessListIncludesWarehouseSummary(t *testing.T) {
mock := organizationAccessMock(t)
now := time.Now()
organizationRows := sqlmock.NewRows([]string{"id", "code", "name", "status", "default_timezone", "created_at", "updated_at", "is_default"}).
AddRow(2, "TWO", "无仓库公司", "active", "Asia/Shanghai", now, now, false).
AddRow(3, "THREE", "停用仓库公司", "active", "Asia/Shanghai", now, now, false).
AddRow(4, "FOUR", "启用仓库公司", "active", "Asia/Shanghai", now, now, true)
mock.ExpectQuery("SELECT o.id,o.code,o.name,o.status").WithArgs(int64(7), int64(7)).WillReturnRows(organizationRows)
warehouseColumns := []string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}
// 无仓库:查询返回空集,摘要为 null。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows(warehouseColumns))
// 仓库停用:仍返回该公司自己的仓库与库位,状态为停用。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(3)).
WillReturnRows(sqlmock.NewRows(warehouseColumns).AddRow(30, 3, "D3", "停用仓库", "inactive", 31, "L3", "停用仓库的库位", "active"))
// 启用唯一仓库加启用库位。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(4)).
WillReturnRows(sqlmock.NewRows(warehouseColumns).AddRow(40, 4, "D4", "启用仓库", "active", 41, "L4", "启用仓库的库位", "active"))
access, err := NewOrganizationAccessService().List(7)
if err != nil {
t.Fatal(err)
}
if len(access.Organizations) != 3 || access.DefaultOrganizationID == nil || *access.DefaultOrganizationID != 4 {
t.Fatalf("公司列表错误 %+v", access)
}
byID := make(map[int64]int, len(access.Organizations))
for i, item := range access.Organizations {
byID[item.ID] = i
}
if warehouse := access.Organizations[byID[2]].Warehouse; warehouse != nil {
t.Fatalf("无仓库公司应返回空摘要,实际 %+v", warehouse)
}
disabled := access.Organizations[byID[3]].Warehouse
if disabled == nil || disabled.ID != 30 || disabled.Status != "inactive" ||
disabled.Location == nil || disabled.Location.ID != 31 || disabled.Location.Status != "active" {
t.Fatalf("停用仓库摘要错误 %+v", disabled)
}
enabled := access.Organizations[byID[4]].Warehouse
if enabled == nil || enabled.ID != 40 || enabled.Status != "active" ||
enabled.Location == nil || enabled.Location.ID != 41 || enabled.Location.Status != "active" {
t.Fatalf("启用仓库摘要错误 %+v", enabled)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}