From 970ba5ccf792a2abdbe3720badae68caac7239ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Fri, 9 Oct 2026 10:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=84=E7=BB=87=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=B8=BA=E6=AF=8F=E5=AE=B6=E5=85=AC=E5=8F=B8=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组织初始化接口 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 集成用例, 覆盖启用唯一仓库加启用库位、无仓库、仓库停用三类。 --- internal/handler/organization_context_test.go | 6 ++ internal/model/organization.go | 27 ++++++- internal/repository/organization_access.go | 19 ++++- .../repository/organization_access_test.go | 45 ++++++++++++ internal/repository/warehouse.go | 27 +++++++ internal/service/organization_access.go | 40 +++-------- internal/service/organization_access_test.go | 71 +++++++++++++++++++ 7 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 internal/service/organization_access_test.go diff --git a/internal/handler/organization_context_test.go b/internal/handler/organization_context_test.go index 98137cea..8f4bc8b4 100644 --- a/internal/handler/organization_context_test.go +++ b/internal/handler/organization_context_test.go @@ -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) } diff --git a/internal/model/organization.go b/internal/model/organization.go index 4b23885e..69b6d2ef 100644 --- a/internal/model/organization.go +++ b/internal/model/organization.go @@ -5,9 +5,32 @@ 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"` - DefaultOrganizationID *int64 `json:"default_organization_id"` + Organizations []OrganizationSummary `json:"organizations"` + DefaultOrganizationID *int64 `json:"default_organization_id"` } type OrganizationAuditLog struct { diff --git a/internal/repository/organization_access.go b/internal/repository/organization_access.go index 8b7d94fe..83379d35 100644 --- a/internal/repository/organization_access.go +++ b/internal/repository/organization_access.go @@ -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) { diff --git a/internal/repository/organization_access_test.go b/internal/repository/organization_access_test.go index 69aeed2d..0a4dbd2e 100644 --- a/internal/repository/organization_access_test.go +++ b/internal/repository/organization_access_test.go @@ -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) { diff --git a/internal/repository/warehouse.go b/internal/repository/warehouse.go index a48db25e..cff351ca 100644 --- a/internal/repository/warehouse.go +++ b/internal/repository/warehouse.go @@ -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 { diff --git a/internal/service/organization_access.go b/internal/service/organization_access.go index d96505cb..c559d29b 100644 --- a/internal/service/organization_access.go +++ b/internal/service/organization_access.go @@ -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 { diff --git a/internal/service/organization_access_test.go b/internal/service/organization_access_test.go new file mode 100644 index 00000000..5a22aa82 --- /dev/null +++ b/internal/service/organization_access_test.go @@ -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) + } +}