组织初始化接口 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 集成用例,
覆盖启用唯一仓库加启用库位、无仓库、仓库停用三类。
140 lines
6.0 KiB
Go
140 lines
6.0 KiB
Go
package repository
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"strings"
|
||
|
||
"github.com/wmms/wmms/internal/model"
|
||
"github.com/wmms/wmms/pkg/database"
|
||
)
|
||
|
||
// ErrWarehouseNotFound 表示仓库 id 不存在,或这条仓库不属于当前组织。
|
||
// 建库位时的 INSERT...SELECT 影响 0 行就是这种情况,接口据此回错,
|
||
// 而不是把「没写成」报成"创建成功"。
|
||
var ErrWarehouseNotFound = errors.New("仓库不存在,可能已被删除或不属于当前组织")
|
||
|
||
// ErrDuplicateWarehouse 表示该公司已经有仓库。warehouses(organization_id) 上的
|
||
// 唯一约束把一个公司限制成一个仓库,停用的仓库同样占名额,不能再建第二个。
|
||
var ErrDuplicateWarehouse = errors.New("本公司已配置仓库,请维护现有仓库")
|
||
|
||
// ErrDuplicateLocation 表示该仓库已经有库位。warehouse_locations(warehouse_id)
|
||
// 上的唯一约束把一个仓库限制成一个库位,停用的库位同样占名额。
|
||
var ErrDuplicateLocation = errors.New("本仓库已配置库位,请维护现有库位")
|
||
|
||
// translateDuplicate 把唯一键冲突翻译成可直接展示的中文错误,其余原样返回。
|
||
func translateDuplicate(err error, duplicate error) error {
|
||
if err != nil && strings.Contains(err.Error(), "Duplicate entry") {
|
||
return duplicate
|
||
}
|
||
return err
|
||
}
|
||
|
||
type WarehouseRepository struct{}
|
||
|
||
func NewWarehouseRepository() *WarehouseRepository { return &WarehouseRepository{} }
|
||
|
||
func (r *WarehouseRepository) List(organizationID int64) ([]model.Warehouse, error) {
|
||
rows, err := database.DB.Query(`SELECT w.id, w.organization_id, w.code, w.name, w.status,
|
||
l.id, l.code, l.name, l.status
|
||
FROM warehouses w
|
||
LEFT JOIN warehouse_locations l ON l.warehouse_id = w.id
|
||
WHERE w.organization_id = ? ORDER BY w.code, l.code`, organizationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
items := make([]model.Warehouse, 0)
|
||
index := make(map[int64]int)
|
||
for rows.Next() {
|
||
var warehouse model.Warehouse
|
||
var locationID sql.NullInt64
|
||
var locationCode, locationName, locationStatus sql.NullString
|
||
if err := rows.Scan(&warehouse.ID, &warehouse.OrganizationID, &warehouse.Code, &warehouse.Name, &warehouse.Status, &locationID, &locationCode, &locationName, &locationStatus); err != nil {
|
||
return nil, err
|
||
}
|
||
position, exists := index[warehouse.ID]
|
||
if !exists {
|
||
warehouse.Locations = []model.WarehouseLocation{}
|
||
items = append(items, warehouse)
|
||
position = len(items) - 1
|
||
index[warehouse.ID] = position
|
||
}
|
||
if locationID.Valid {
|
||
items[position].Locations = append(items[position].Locations, model.WarehouseLocation{ID: locationID.Int64, WarehouseID: warehouse.ID, Code: locationCode.String, Name: locationName.String, Status: locationStatus.String})
|
||
}
|
||
}
|
||
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 {
|
||
return translateDuplicate(err, ErrDuplicateWarehouse)
|
||
}
|
||
item.ID, err = result.LastInsertId()
|
||
return err
|
||
}
|
||
|
||
func (r *WarehouseRepository) Update(item *model.Warehouse) error {
|
||
return execExpectingRow(`UPDATE warehouses SET name = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Status, item.ID, item.OrganizationID)
|
||
}
|
||
|
||
func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error {
|
||
// 仓库归属条件写在 SELECT 里,避免「先查后插」之间的竞态。
|
||
//
|
||
// 匹配不到仓库时这条语句影响 0 行,但 MySQL 不报错:err 为 nil,LastInsertId()
|
||
// 返回 0。此前只看 err,接口回 201「创建成功」而库里一行都没有(实测请求前后
|
||
// warehouse_locations 行数都是 1,刷新后库位消失)。所以这里和按主键更新一样,
|
||
// 以 RowsAffected 为准——INSERT 的 RowsAffected 就是实际插入行数,不受
|
||
// clientFoundRows 影响。
|
||
result, err := database.DB.Exec(`INSERT INTO warehouse_locations (warehouse_id, code, name, status)
|
||
SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`, item.Code, item.Name, item.Status, item.WarehouseID, organizationID)
|
||
if err != nil {
|
||
return translateDuplicate(err, ErrDuplicateLocation)
|
||
}
|
||
affected, err := result.RowsAffected()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if affected == 0 {
|
||
return ErrWarehouseNotFound
|
||
}
|
||
item.ID, err = result.LastInsertId()
|
||
return err
|
||
}
|
||
|
||
func (r *WarehouseRepository) UpdateLocation(item *model.WarehouseLocation, organizationID int64) error {
|
||
// JOIN 形式的 UPDATE 同样受 clientFoundRows 影响,影响 0 行也代表库位不存在
|
||
// 或不属于该组织(已实测)。
|
||
return execExpectingRow(`UPDATE warehouse_locations l JOIN warehouses w ON w.id = l.warehouse_id
|
||
SET l.name = ?, l.status = ? WHERE l.id = ? AND w.organization_id = ?`, item.Name, item.Status, item.ID, organizationID)
|
||
}
|