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:
parent
838889ce9f
commit
970ba5ccf7
|
|
@ -54,6 +54,9 @@ func TestOrganizationContextListWithoutOrganizationHeader(t *testing.T) {
|
||||||
mock := contextMock(t)
|
mock := contextMock(t)
|
||||||
expectContextPrincipal(mock, "warehouse_admin")
|
expectContextPrincipal(mock, "warehouse_admin")
|
||||||
expectContextAccess(mock, true)
|
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()
|
router := gin.New()
|
||||||
NewHandler().RegisterRoutes(router)
|
NewHandler().RegisterRoutes(router)
|
||||||
req := httptest.NewRequest("GET", "/api/auth/organizations", nil)
|
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`) {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"default_organization_id":2`) {
|
||||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
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 {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,31 @@ import (
|
||||||
"time"
|
"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 {
|
type OrganizationAccess struct {
|
||||||
Organizations []Organization `json:"organizations"`
|
Organizations []OrganizationSummary `json:"organizations"`
|
||||||
DefaultOrganizationID *int64 `json:"default_organization_id"`
|
DefaultOrganizationID *int64 `json:"default_organization_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ func (r *OrganizationRepository) ListAccessible(userID int64) (*model.Organizati
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
access := &model.OrganizationAccess{Organizations: make([]model.Organization, 0)}
|
access := &model.OrganizationAccess{Organizations: make([]model.OrganizationSummary, 0)}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item model.Organization
|
var item model.OrganizationSummary
|
||||||
var isDefault bool
|
var isDefault bool
|
||||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Status, &item.DefaultTimezone, &item.CreatedAt, &item.UpdatedAt, &isDefault); err != nil {
|
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Status, &item.DefaultTimezone, &item.CreatedAt, &item.UpdatedAt, &isDefault); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -32,7 +32,20 @@ func (r *OrganizationRepository) ListAccessible(userID int64) (*model.Organizati
|
||||||
access.DefaultOrganizationID = &id
|
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) {
|
func (r *OrganizationRepository) CanAccess(userID, organizationID int64) (bool, error) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
// 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.
|
// a rollback must remove both records, and reads must require an organization.
|
||||||
func TestOrganizationAuditTransactionAndScope(t *testing.T) {
|
func TestOrganizationAuditTransactionAndScope(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,33 @@ func (r *WarehouseRepository) List(organizationID int64) ([]model.Warehouse, err
|
||||||
return items, rows.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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -72,17 +72,10 @@ func (s *OrganizationAccessService) Validate(userID, organizationID int64) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationContext struct {
|
// 复用 model 中的仓库摘要结构,切换接口与组织初始化列表字段名、结构完全一致。
|
||||||
ID int64 `json:"id"`
|
type LocationContext = model.LocationSummary
|
||||||
Name string `json:"name"`
|
type WarehouseContext = model.WarehouseSummary
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
type WarehouseContext struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Location *LocationContext `json:"location"`
|
|
||||||
}
|
|
||||||
type OrganizationContext struct {
|
type OrganizationContext struct {
|
||||||
OrganizationID int64 `json:"organization_id"`
|
OrganizationID int64 `json:"organization_id"`
|
||||||
Organization model.Organization `json:"organization"`
|
Organization model.Organization `json:"organization"`
|
||||||
|
|
@ -95,11 +88,12 @@ func (s *OrganizationAccessService) Switch(userID, organizationID, previousID in
|
||||||
if organizationID <= 0 {
|
if organizationID <= 0 {
|
||||||
return nil, ErrInvalidOrganizationID
|
return nil, ErrInvalidOrganizationID
|
||||||
}
|
}
|
||||||
|
// List 已为每家公司解析好仓库摘要,直接复用,避免重复查询。
|
||||||
access, err := s.List(userID)
|
access, err := s.List(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var target *model.Organization
|
var target *model.OrganizationSummary
|
||||||
for i := range access.Organizations {
|
for i := range access.Organizations {
|
||||||
if access.Organizations[i].ID == organizationID {
|
if access.Organizations[i].ID == organizationID {
|
||||||
target = &access.Organizations[i]
|
target = &access.Organizations[i]
|
||||||
|
|
@ -109,24 +103,10 @@ func (s *OrganizationAccessService) Switch(userID, organizationID, previousID in
|
||||||
if target == nil {
|
if target == nil {
|
||||||
return nil, ErrOrganizationInaccessible
|
return nil, ErrOrganizationInaccessible
|
||||||
}
|
}
|
||||||
result := &OrganizationContext{OrganizationID: organizationID, Organization: *target}
|
result := &OrganizationContext{
|
||||||
warehouses, err := repository.NewWarehouseRepository().List(organizationID)
|
OrganizationID: organizationID,
|
||||||
if err != nil {
|
Organization: target.Organization,
|
||||||
return nil, err
|
Warehouse: target.Warehouse,
|
||||||
}
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
oldValue, err := json.Marshal(map[string]int64{"organization_id": previousID})
|
oldValue, err := json.Marshal(map[string]int64{"organization_id": previousID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
71
internal/service/organization_access_test.go
Normal file
71
internal/service/organization_access_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user