material_manage_system/internal/service/admin_master.go
2026-10-09 10:38:02 +08:00

466 lines
16 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 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")
// errInvalidCategoryParent 表示上级分类无效:不属于当前公司、超过两级、或指向自己。
// 分类只允许两级(顶层 + 小类),跨公司父级会让分类树越界。
var errInvalidCategoryParent = errors.New("请选择有效的上级分类")
// errInvalidCodeRuleCategory 表示编码规则引用的分类无效:不属于当前公司或不存在。
var errInvalidCodeRuleCategory = errors.New("请选择有效的分类")
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) {
if orgID <= 0 {
return nil, ErrInvalidOrganizationID
}
return s.repo.ListCategories(orgID)
}
// validateCategoryParent 校验上级分类:必须属于当前公司、本身是顶层分类(只允许两级)、
// 且不能把自己设成自己的父级。categoryID 为 0 表示新建,此时只需排除父级非法。
func (s *MasterDataService) validateCategoryParent(organizationID, categoryID int64, parentID *int64) error {
if parentID == nil {
return nil
}
if *parentID <= 0 {
return errInvalidCategoryParent
}
if categoryID != 0 && *parentID == categoryID {
return errInvalidCategoryParent
}
parent, err := s.repo.FindCategory(organizationID, *parentID)
if err != nil {
return err
}
if parent == nil || parent.ParentID != nil {
return errInvalidCategoryParent
}
return nil
}
// CreateCategory 新建分类。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
// repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。
func (s *MasterDataService) CreateCategory(item *model.MaterialCategory) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
normalizeCategory(item)
if item.Code == "" || item.Name == "" {
return errInvalidMasterData
}
if err := s.validateCategoryParent(item.OrganizationID, 0, item.ParentID); err != nil {
return err
}
return s.repo.CreateCategory(item)
}
// UpdateCategory 更新分类。id 来自路由参数,必须显式存在。
// code 不可变,理由见文件头的约定:这里只认名称、描述、上级和状态。
func (s *MasterDataService) UpdateCategory(item *model.MaterialCategory) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
if item.ID == 0 {
return errUpdateRequiresID
}
normalizeCategory(item)
if item.Name == "" {
return errInvalidMasterData
}
if err := s.validateCategoryParent(item.OrganizationID, item.ID, item.ParentID); err != nil {
return err
}
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) {
if orgID <= 0 {
return nil, ErrInvalidOrganizationID
}
return s.repo.ListUnits(orgID)
}
// CreateUnit 新建单位。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。
func (s *MasterDataService) CreateUnit(item *model.Unit) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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) {
if orgID <= 0 {
return nil, ErrInvalidOrganizationID
}
return s.repo.ListCodeRules(orgID)
}
// NextMaterialCode 预览新增物料时按小类规则自动生成的编码。这里只读不落库,
// 返回的编码只是候选值;真正占号发生在创建物料的事务里。
func (s *MasterDataService) NextMaterialCode(orgID, categoryID int64) (*model.NextMaterialCode, error) {
if orgID <= 0 {
return nil, ErrInvalidOrganizationID
}
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
}
// validateCodeRuleCategory 要求编码规则引用的小类属于当前公司,避免 A 给 B 的
// 分类配规则。
func (s *MasterDataService) validateCodeRuleCategory(organizationID, categoryID int64) error {
category, err := s.repo.FindCategory(organizationID, categoryID)
if err != nil {
return err
}
if category == nil {
return errInvalidCodeRuleCategory
}
return nil
}
// CreateCodeRule 新建编码规则。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。
func (s *MasterDataService) CreateCodeRule(item *model.CodeRule) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
if err := prepareCodeRule(item); err != nil {
return err
}
if err := s.validateCodeRuleCategory(item.OrganizationID, item.CategoryID); err != nil {
return err
}
return s.repo.CreateCodeRule(item)
}
// UpdateCodeRule 更新编码规则。id 来自路由参数,必须显式存在。
func (s *MasterDataService) UpdateCodeRule(item *model.CodeRule) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
if item.ID == 0 {
return errUpdateRequiresID
}
if err := prepareCodeRule(item); err != nil {
return err
}
if err := s.validateCodeRuleCategory(item.OrganizationID, item.CategoryID); 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 orgID <= 0 {
return nil, 0, ErrInvalidOrganizationID
}
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 item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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) {
if orgID <= 0 {
return nil, ErrInvalidOrganizationID
}
return s.repo.List(orgID)
}
// Create 新建仓库。不读取 item.ID,脏 id 不会把新建变成覆盖。
func (s *WarehouseService) Create(item *model.Warehouse) error {
if item.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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.OrganizationID <= 0 {
return ErrInvalidOrganizationID
}
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 {
if orgID <= 0 {
return ErrInvalidOrganizationID
}
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 orgID <= 0 {
return ErrInvalidOrganizationID
}
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"
}
}