material_manage_system/internal/repository/warehouse.go
文睿 张 970ba5ccf7 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 集成用例,
  覆盖启用唯一仓库加启用库位、无仓库、仓库停用三类。
2026-10-09 10:43:35 +08:00

140 lines
6.0 KiB
Go
Raw Permalink 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.

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)
}