package service import ( "database/sql" "errors" "log" "unicode/utf8" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/repository" ) var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型") var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字") var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字") const maxMaterialNameLength = 200 const maxMaterialSpecLength = 200 // 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 utf8.RuneCountInString(req.Name) > maxMaterialNameLength { return nil, ErrMaterialNameTooLong } if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength { return nil, ErrMaterialSpecTooLong } 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 utf8.RuneCountInString(req.Name) > maxMaterialNameLength { return nil, ErrMaterialNameTooLong } if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength { return nil, ErrMaterialSpecTooLong } 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 }