- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致 - 单位下拉增加未选择时的默认提示 - init.sql 补充 30 个常用计量单位种子数据 - init.sql 新增 material_import_logs 表,为批量导入提供落库 - 新增物料批量导入实施计划文档,相关测试期望值同步更新
266 lines
7.4 KiB
Go
266 lines
7.4 KiB
Go
package service
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"log"
|
||
|
||
"github.com/wmms/wmms/internal/model"
|
||
"github.com/wmms/wmms/internal/repository"
|
||
)
|
||
|
||
var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型")
|
||
|
||
// ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司,
|
||
// 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。
|
||
var ErrInvalidMaterialUnit = errors.New("请选择有效的单位")
|
||
|
||
type MaterialService struct {
|
||
repo *repository.MaterialRepo
|
||
logSvc *MaterialLogService
|
||
}
|
||
|
||
func NewMaterialService() *MaterialService {
|
||
return &MaterialService{
|
||
repo: repository.NewMaterialRepo(),
|
||
logSvc: NewMaterialLogService(),
|
||
}
|
||
}
|
||
|
||
func (s *MaterialService) validateCategory(categoryID *int64, organizationID int64) error {
|
||
if categoryID == nil || *categoryID <= 0 {
|
||
return ErrInvalidMaterialCategory
|
||
}
|
||
exists, err := s.repo.IsActiveSubcategory(*categoryID, organizationID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
return ErrInvalidMaterialCategory
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateUnit 要求单位属于当前公司且启用。unitID 为 nil 表示本次不设置单位,
|
||
// 直接通过;更新时"保留原先已停用引用"的分支由调用方先判断后跳过本校验。
|
||
func (s *MaterialService) validateUnit(unitID *int64, organizationID int64) error {
|
||
if unitID == nil {
|
||
return nil
|
||
}
|
||
if *unitID <= 0 {
|
||
return ErrInvalidMaterialUnit
|
||
}
|
||
active, err := s.repo.IsActiveUnit(*unitID, organizationID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !active {
|
||
return ErrInvalidMaterialUnit
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
|
||
organizationID := req.OrganizationID
|
||
if organizationID <= 0 {
|
||
return nil, ErrInvalidOrganizationID
|
||
}
|
||
if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
|
||
return nil, err
|
||
}
|
||
// 单位校验必须在写库之前:引用非法时直接失败,既不落库也不推进编码号段。
|
||
if err := s.validateUnit(req.BaseUnitID, organizationID); err != nil {
|
||
return nil, err
|
||
}
|
||
unit := req.Unit
|
||
if unit == "" {
|
||
unit = "个"
|
||
}
|
||
|
||
var materialImage *string
|
||
if req.MaterialImage != nil {
|
||
materialImage = req.MaterialImage
|
||
}
|
||
|
||
m := &model.Material{
|
||
OrganizationID: organizationID,
|
||
MaterialCode: req.MaterialCode,
|
||
Name: req.Name,
|
||
MaterialImage: materialImage,
|
||
Spec: req.Spec,
|
||
CategoryID: req.CategoryID,
|
||
BaseUnitID: req.BaseUnitID,
|
||
Unit: unit,
|
||
Status: "active",
|
||
Version: 1,
|
||
}
|
||
if req.ManufacturerModel != nil {
|
||
m.ManufacturerModel = req.ManufacturerModel
|
||
}
|
||
|
||
// 编码由提交值和编码规则共同决定:值仍等于规则产物说明用户没改过,落库时
|
||
// 顺延号段;改过(或者小类没有规则)则原样保存,号段不动。
|
||
err := s.repo.CreateWithCodeRule(m, req.MaterialCode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 记录日志
|
||
if err := s.logSvc.LogCreate(m, operator); err != nil {
|
||
log.Printf("Warning: failed to create material log: %v", err)
|
||
}
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// Obsolete 作废本公司物料。物料读取错误不再忽略:不存在(含不属于本公司)会
|
||
// 以 sql.ErrNoRows 冒到接口,映射成 404。状态更新与变更日志在同一事务里完成,
|
||
// 日志写失败则整体回滚,不会出现"改了状态却没有日志"。
|
||
func (s *MaterialService) Obsolete(organizationID, id int64, operator string) error {
|
||
material, err := s.GetByID(organizationID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
tx, err := repository.BeginTx()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if err := s.repo.ObsoleteTx(tx, organizationID, id); err != nil {
|
||
return err
|
||
}
|
||
updated := *material
|
||
updated.Status = "obsolete"
|
||
updated.Version++
|
||
if err := s.logSvc.LogUpdateTx(tx, &updated, material, operator); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// Restore 恢复本公司物料,语义与 Obsolete 对称。
|
||
func (s *MaterialService) Restore(organizationID, id int64, operator string) error {
|
||
material, err := s.GetByID(organizationID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
tx, err := repository.BeginTx()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if err := s.repo.RestoreTx(tx, organizationID, id); err != nil {
|
||
return err
|
||
}
|
||
updated := *material
|
||
updated.Status = "active"
|
||
updated.Version++
|
||
if err := s.logSvc.LogUpdateTx(tx, &updated, material, operator); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func (s *MaterialService) GetByID(organizationID, id int64) (*model.Material, error) {
|
||
m := &model.Material{}
|
||
err := s.repo.GetByID(organizationID, id, m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (s *MaterialService) List(organizationID int64, search, status string, limit, offset int) ([]model.Material, int64, error) {
|
||
return s.repo.List(organizationID, search, status, limit, offset)
|
||
}
|
||
|
||
func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
|
||
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
|
||
oldMaterial, err := s.GetByID(organizationID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if req.CategoryID == nil || *req.CategoryID <= 0 {
|
||
return nil, ErrInvalidMaterialCategory
|
||
}
|
||
if oldMaterial.CategoryID == nil || *req.CategoryID != *oldMaterial.CategoryID {
|
||
if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
// 单位:改选了就必须是本公司启用项;保持原值(哪怕原本已停用)不校验。
|
||
if req.BaseUnitID != nil && (oldMaterial.BaseUnitID == nil || *req.BaseUnitID != *oldMaterial.BaseUnitID) {
|
||
if err := s.validateUnit(req.BaseUnitID, organizationID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
err = s.repo.Update(organizationID, id, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 获取修改后的数据
|
||
newMaterial, err := s.GetByID(organizationID, id)
|
||
if err != nil {
|
||
log.Printf("Warning: failed to get new material for logging: %v", err)
|
||
}
|
||
|
||
// 记录日志
|
||
if err := s.logSvc.LogUpdate(newMaterial, oldMaterial, operator); err != nil {
|
||
log.Printf("Warning: failed to create material log: %v", err)
|
||
}
|
||
|
||
return newMaterial, nil
|
||
}
|
||
|
||
func (s *MaterialService) Delete(organizationID, id int64, operator string) error {
|
||
// 获取删除前的数据;不存在或不属于本公司时不再继续。
|
||
material, err := s.GetByID(organizationID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := s.repo.Delete(organizationID, id); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录日志
|
||
if err := s.logSvc.LogDelete(material, operator); err != nil {
|
||
log.Printf("Warning: failed to create material log: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *MaterialService) BelongsToCompany(organizationID, id int64) (bool, error) {
|
||
return s.repo.Exists(organizationID, id)
|
||
}
|
||
|
||
func (s *MaterialService) Exists(organizationID, id int64) (bool, error) {
|
||
m := &model.Material{}
|
||
err := s.repo.GetByID(organizationID, id, m)
|
||
if err == sql.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
return err == nil, err
|
||
}
|
||
|
||
// IsActive is intentionally separate from Exists: historical documents still
|
||
// need to resolve retired materials, while new warehouse operations do not.
|
||
func (s *MaterialService) IsActive(organizationID, id int64) (bool, error) {
|
||
m := &model.Material{}
|
||
err := s.repo.GetByID(organizationID, id, m)
|
||
if err == sql.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return m.Status == "active", nil
|
||
}
|