material_manage_system/internal/service/material.go
文睿 张 616a4a3b1d feat: 物料增加初始单位,补充物料批量上传计划
- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致
- 单位下拉增加未选择时的默认提示
- init.sql 补充 30 个常用计量单位种子数据
- init.sql 新增 material_import_logs 表,为批量导入提供落库
- 新增物料批量导入实施计划文档,相关测试期望值同步更新
2026-10-10 14:15:49 +08:00

266 lines
7.4 KiB
Go
Raw 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 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
}