package service import ( "errors" "strings" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/repository" ) // 本文件里的「编码」字段——分类/单位/仓库/库位的 code、伙伴的 partner_code—— // 一律不可变。它们是编码规则前缀的来源(init.sql 里 code_rules 的种子数据直接取 // material_categories.code)或者对外的业务编号,改掉会让已经生成的历史编码、单据 // 与之脱节,而且事后查不回来。前端编辑时这些输入框都是 :disabled 的,与之对应: // 各 Update* 既不校验也不使用请求体里的编码,repository 的 UPDATE 也不写这些列。 // 因此 Update* 只校验名称等可改字段——从前这里复用 Create* 的校验,要求编码非空, // 形成「必须传、传了却被丢弃」的怪要求。 var errInvalidMasterData = errors.New("code and name are required") var errUpdateRequiresID = errors.New("id is required for update") var ErrProtectedRole = repository.ErrProtectedRole type OrganizationService struct { repo *repository.OrganizationRepository } func NewOrganizationService() *OrganizationService { return &OrganizationService{repo: repository.NewOrganizationRepository()} } func (s *OrganizationService) List() ([]model.Organization, error) { return s.repo.List() } func (s *OrganizationService) ListRoles() ([]model.Role, error) { return s.repo.ListRoles() } func (s *OrganizationService) ListPermissions() ([]model.Permission, error) { return s.repo.ListPermissions() } func (s *OrganizationService) UpdateRole(roleID int64, req *model.UpdateRoleRequest) error { req.Name = strings.TrimSpace(req.Name) if req.Name == "" { return errors.New("role name is required") } return s.repo.UpdateRole(roleID, req.Name, req.Enabled, req.PermissionCodes) } func (s *OrganizationService) Create(item *model.Organization) error { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) if item.Code == "" || item.Name == "" { return errInvalidMasterData } applyDefaults(&item.Status, &item.DefaultTimezone) return s.repo.Create(item) } func (s *OrganizationService) Update(item *model.Organization) error { item.Name = strings.TrimSpace(item.Name) if item.Name == "" { return errInvalidMasterData } applyDefaults(&item.Status, &item.DefaultTimezone) return s.repo.Update(item) } func applyDefaults(status, timezone *string) { if *status == "" { *status = "active" } if *timezone == "" { *timezone = "Asia/Shanghai" } } type MasterDataService struct { repo *repository.MasterDataRepository } func NewMasterDataService() *MasterDataService { return &MasterDataService{repo: repository.NewMasterDataRepository()} } func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategory, error) { return s.repo.ListCategories(orgID) } // CreateCategory 新建分类。请求体可能带着前端表单残留的 id,这里不读取 item.ID, // repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。 func (s *MasterDataService) CreateCategory(item *model.MaterialCategory) error { normalizeCategory(item) if item.Code == "" || item.Name == "" { return errInvalidMasterData } return s.repo.CreateCategory(item) } // UpdateCategory 更新分类。id 来自路由参数,必须显式存在。 // code 不可变,理由见文件头的约定:这里只认名称、描述、上级和状态。 func (s *MasterDataService) UpdateCategory(item *model.MaterialCategory) error { if item.ID == 0 { return errUpdateRequiresID } normalizeCategory(item) if item.Name == "" { return errInvalidMasterData } return s.repo.UpdateCategory(item) } // normalizeCategory 是新建与更新共用的字段清理和默认值补全。 // 编码是否必填由两个入口各自判断:新建必填,更新不看。 func normalizeCategory(item *model.MaterialCategory) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) item.Description = strings.TrimSpace(item.Description) if item.Status == "" { item.Status = "active" } } func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) { return s.repo.ListUnits(orgID) } // CreateUnit 新建单位。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。 func (s *MasterDataService) CreateUnit(item *model.Unit) error { if err := normalizeUnit(item); err != nil { return err } if item.Code == "" || item.Name == "" { return errInvalidMasterData } return s.repo.CreateUnit(item) } // UpdateUnit 更新单位。id 来自路由参数,必须显式存在。 // code 不可变,理由见文件头的约定;小数位是可以改的。 func (s *MasterDataService) UpdateUnit(item *model.Unit) error { if item.ID == 0 { return errUpdateRequiresID } if err := normalizeUnit(item); err != nil { return err } if item.Name == "" { return errInvalidMasterData } return s.repo.UpdateUnit(item) } // normalizeUnit 是新建与更新共用的字段清理、默认值补全和小数位值域校验。 // 小数位在编辑时也能改,所以两个入口都要查。编码是否必填由两个入口各自判断。 func normalizeUnit(item *model.Unit) error { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) if item.Status == "" { item.Status = "active" } if item.PrecisionScale < 0 || item.PrecisionScale > 6 { return errors.New("precision_scale must be between 0 and 6") } return nil } func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) { return s.repo.ListCodeRules(orgID) } // NextMaterialCode 预览新增物料时按小类规则自动生成的编码。这里只读不落库, // 返回的编码只是候选值;真正占号发生在创建物料的事务里。 func (s *MasterDataService) NextMaterialCode(orgID, categoryID int64) (*model.NextMaterialCode, error) { rule, err := s.repo.FindActiveCodeRule(orgID, categoryID) if err != nil { return nil, err } if rule == nil { return &model.NextMaterialCode{Available: false}, nil } return &model.NextMaterialCode{Available: true, Code: rule.FormatCode(rule.NextSequence)}, nil } // CreateCodeRule 新建编码规则。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。 func (s *MasterDataService) CreateCodeRule(item *model.CodeRule) error { if err := prepareCodeRule(item); err != nil { return err } return s.repo.CreateCodeRule(item) } // UpdateCodeRule 更新编码规则。id 来自路由参数,必须显式存在。 func (s *MasterDataService) UpdateCodeRule(item *model.CodeRule) error { if item.ID == 0 { return errUpdateRequiresID } if err := prepareCodeRule(item); err != nil { return err } return s.repo.UpdateCodeRule(item) } // prepareCodeRule 是新建与更新共用的字段清理、校验和默认值补全。 func prepareCodeRule(item *model.CodeRule) error { item.Prefix = strings.TrimSpace(item.Prefix) if item.CategoryID <= 0 || item.Prefix == "" { return errors.New("category_id and prefix are required") } if item.SequenceLength < 1 || item.SequenceLength > 12 { return errors.New("sequence_length must be between 1 and 12") } if item.NextSequence <= 0 { item.NextSequence = 1 } if item.Status == "" { item.Status = "active" } return nil } type PartnerService struct{ repo *repository.PartnerRepository } func NewPartnerService() *PartnerService { return &PartnerService{repo: repository.NewPartnerRepository()} } func (s *PartnerService) List(orgID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) { if role != "" && role != "supplier" && role != "customer" { return nil, 0, errors.New("role must be supplier or customer") } return s.repo.List(orgID, role, limit, offset, category) } // Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID, // repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。 func (s *PartnerService) Create(item *model.BusinessPartner) error { if err := normalizePartner(item); err != nil { return err } if item.PartnerCode == "" || item.LegalName == "" { return errors.New("partner_code and legal_name are required") } return s.repo.Create(item) } // Update 更新伙伴。id 来自路由参数,必须显式存在。 // partner_code 不可变,理由见文件头的约定:它是伙伴对外的业务编号, // 单据引用的就是它。这里只认企业名称、角色、结算等可改字段。 func (s *PartnerService) Update(item *model.BusinessPartner) error { if item.ID == 0 { return errUpdateRequiresID } if err := normalizePartner(item); err != nil { return err } if item.LegalName == "" { return errors.New("legal_name is required") } return s.repo.Update(item) } // normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验: // 至少要有供应商或客户其中一个角色,编辑时也可能把两个都取消掉。 // 编码是否必填由两个入口各自判断。 func normalizePartner(item *model.BusinessPartner) error { item.PartnerCode = strings.TrimSpace(item.PartnerCode) item.LegalName = strings.TrimSpace(item.LegalName) if !item.IsSupplier && !item.IsCustomer { return errors.New("at least one partner role is required") } for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} { if *categories == nil { continue // 更新时未传分类保留原值;显式空数组表示清空。 } normalized := make([]string, 0, 2) seen := make(map[string]bool) for _, category := range *categories { if category != "material" && category != "finished_product" { return errors.New("业务分类只能选择物料级(生产)或成品级(经营买卖)") } if !seen[category] { normalized = append(normalized, category) seen[category] = true } } *categories = normalized } if item.IsSupplier && item.SupplierStatus == "" { item.SupplierStatus = "active" } if item.IsCustomer && item.CustomerStatus == "" { item.CustomerStatus = "active" } return nil } type WarehouseService struct { repo *repository.WarehouseRepository } func NewWarehouseService() *WarehouseService { return &WarehouseService{repo: repository.NewWarehouseRepository()} } func (s *WarehouseService) List(orgID int64) ([]model.Warehouse, error) { return s.repo.List(orgID) } // Create 新建仓库。不读取 item.ID,脏 id 不会把新建变成覆盖。 func (s *WarehouseService) Create(item *model.Warehouse) error { normalizeWarehouse(item) if item.Code == "" || item.Name == "" { return errInvalidMasterData } return s.repo.Create(item) } // Update 更新仓库。id 来自路由参数,必须显式存在。 // code 不可变,理由见文件头的约定。 func (s *WarehouseService) Update(item *model.Warehouse) error { if item.ID == 0 { return errUpdateRequiresID } normalizeWarehouse(item) if item.Name == "" { return errInvalidMasterData } return s.repo.Update(item) } // normalizeWarehouse 是新建与更新共用的字段清理和默认值补全。 // 编码是否必填由两个入口各自判断:新建必填,更新不看。 func normalizeWarehouse(item *model.Warehouse) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) if item.Status == "" { item.Status = "active" } } // CreateLocation 新建库位。orgID 用于校验仓库归属,item.ID 不参与。 func (s *WarehouseService) CreateLocation(item *model.WarehouseLocation, orgID int64) error { normalizeLocation(item) if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 { return errInvalidMasterData } return s.repo.CreateLocation(item, orgID) } // UpdateLocation 更新库位。id 来自路由参数,必须显式存在。 // code 不可变,理由见文件头的约定;warehouse_id 由路由给出,不能搬到别的仓库去。 func (s *WarehouseService) UpdateLocation(item *model.WarehouseLocation, orgID int64) error { if item.ID == 0 { return errUpdateRequiresID } normalizeLocation(item) if item.Name == "" || item.WarehouseID <= 0 { return errInvalidMasterData } return s.repo.UpdateLocation(item, orgID) } // normalizeLocation 是新建与更新共用的字段清理和默认值补全。 // 编码是否必填由两个入口各自判断:新建必填,更新不看。 func normalizeLocation(item *model.WarehouseLocation) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) if item.Status == "" { item.Status = "active" } }