UPDATE 漏列: - code_rules 的 SET 补上 category_id。此前改分类提示"保存成功"但不落库, 是唯一用户可达的活缺陷(同页的分类下拉没有 disabled,且规则只能改不能删, 没有"删了重建"这条退路) - code_rules 有 uk_code_rules_org_category,补列后改到已占用的分类会撞唯一键, 新建与更新统一翻成中文的 ErrDuplicateCodeRule - 分类/单位/仓库/库位/伙伴的编码定为不可变:分类编码是编码规则前缀的来源 (init.sql 的种子数据直接取 material_categories.code),伙伴编码是对外业务 编号,改掉会让历史数据脱节且查不回来。service 的 Update* 不再要求编码非空 ——原先是"必须传、传了却被丢弃";新建侧的必填校验保留 - 顺带补齐两处同款漏网:organization.go 的 code 与 partner.go 的 partner_code (前者 service 本就不校验编码,自洽,无需改) 记录不存在时的静默成功: - DSN 开启 clientFoundRows,让 RowsAffected 返回"匹配"行数而非"实际改变"行数。 否则"编辑时一个字段都没改"和"记录不存在"都返回 0,无法区分,直接加检查会把 正常保存误判成记录不存在(真 MySQL 8.0 实测) - 新增 repository.execExpectingRow,7 个 Update* 影响 0 行时返回 ErrRecordNotFound; 伙伴走事务内的 SELECT ... FOR UPDATE,只需翻译 sql.ErrNoRows - 既有 4 处 RowsAffected 用法逐处核对过,均不受该 flag 影响 测试:补 6 个 Update* 的存在性检查、编码必填约束的两端、唯一键翻译、DSN 拼接; 全部做过变异验证(抽掉实现后对应测试确实变红)。
341 lines
12 KiB
Go
341 lines
12 KiB
Go
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) ([]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)
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
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"
|
||
}
|
||
}
|