material_manage_system/internal/repository/master_data.go
文睿 张 696e90893b fix: 主数据编辑的两类静默失败
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 拼接;
全部做过变异验证(抽掉实现后对应测试确实变红)。
2026-09-21 17:26:53 +08:00

140 lines
6.2 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 repository
import (
"database/sql"
"errors"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
// ErrDuplicateCodeRule 表示该分类下已经有一条编码规则。code_rules 上有
// uk_code_rules_org_category(organization_id, category_id),新建选到已占用的
// 分类、或者改分类改到已占用的分类,都会撞上它。
var ErrDuplicateCodeRule = errors.New("该分类已有编码规则")
// translateDuplicateCodeRule 把唯一键冲突翻译成可直接展示的中文错误,
// 其余错误原样返回。前端只有拿到中文消息才会照原样弹出,否则显示"请求失败"。
func translateDuplicateCodeRule(err error) error {
if err != nil && strings.Contains(err.Error(), "Duplicate entry") {
return ErrDuplicateCodeRule
}
return err
}
type MasterDataRepository struct{}
func NewMasterDataRepository() *MasterDataRepository { return &MasterDataRepository{} }
func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.MaterialCategory, error) {
rows, err := database.DB.Query(`SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]model.MaterialCategory, 0)
for rows.Next() {
var item model.MaterialCategory
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.Code, &item.Name, &item.Description, &item.ParentID, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) error {
result, err := database.DB.Exec(`INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES (?, ?, ?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.Description, item.ParentID, item.Status)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error {
return execExpectingRow(`UPDATE material_categories SET name = ?, description = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Description, item.ParentID, item.Status, item.ID, item.OrganizationID)
}
func (r *MasterDataRepository) ListUnits(organizationID int64) ([]model.Unit, error) {
rows, err := database.DB.Query(`SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code`, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]model.Unit, 0)
for rows.Next() {
var item model.Unit
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.Code, &item.Name, &item.PrecisionScale, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *MasterDataRepository) CreateUnit(item *model.Unit) error {
result, err := database.DB.Exec(`INSERT INTO units (organization_id, code, name, precision_scale, status) VALUES (?, ?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.PrecisionScale, item.Status)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
func (r *MasterDataRepository) UpdateUnit(item *model.Unit) error {
return execExpectingRow(`UPDATE units SET name = ?, precision_scale = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.PrecisionScale, item.Status, item.ID, item.OrganizationID)
}
func (r *MasterDataRepository) ListCodeRules(organizationID int64) ([]model.CodeRule, error) {
rows, err := database.DB.Query(`SELECT cr.id, cr.organization_id, cr.category_id, c.code, c.name, cr.prefix, cr.sequence_length, cr.next_sequence, cr.status
FROM code_rules cr JOIN material_categories c ON c.id = cr.category_id
WHERE cr.organization_id = ? ORDER BY c.code`, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]model.CodeRule, 0)
for rows.Next() {
var item model.CodeRule
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.CategoryID, &item.CategoryCode, &item.CategoryName, &item.Prefix, &item.SequenceLength, &item.NextSequence, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *MasterDataRepository) CreateCodeRule(item *model.CodeRule) error {
result, err := database.DB.Exec(`INSERT INTO code_rules (organization_id, category_id, prefix, sequence_length, next_sequence, status) VALUES (?, ?, ?, ?, ?, ?)`, item.OrganizationID, item.CategoryID, item.Prefix, item.SequenceLength, item.NextSequence, item.Status)
if err != nil {
return translateDuplicateCodeRule(err)
}
item.ID, err = result.LastInsertId()
return err
}
// UpdateCodeRule 更新编码规则。category_id 必须写进 SET:前端编辑弹窗里的
// 分类下拉没有禁用(与同页的编码输入框不同),而规则又只能改不能删,
// 漏掉这一列会让"改分类"提示成功却完全不落库。
func (r *MasterDataRepository) UpdateCodeRule(item *model.CodeRule) error {
return translateDuplicateCodeRule(execExpectingRow(`UPDATE code_rules SET category_id = ?, prefix = ?, sequence_length = ?, next_sequence = ?, status = ? WHERE id = ? AND organization_id = ?`, item.CategoryID, item.Prefix, item.SequenceLength, item.NextSequence, item.Status, item.ID, item.OrganizationID))
}
// FindActiveCodeRule 取小类当前启用的编码规则,没有规则时返回 (nil, nil)。
// 只读,不加锁:占号发生在创建物料的事务里。
func (r *MasterDataRepository) FindActiveCodeRule(organizationID, categoryID int64) (*model.CodeRule, error) {
query := `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'`
rule := &model.CodeRule{}
err := database.DB.QueryRow(query, organizationID, categoryID).Scan(&rule.ID, &rule.OrganizationID,
&rule.CategoryID, &rule.Prefix, &rule.SequenceLength, &rule.NextSequence, &rule.Status)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return rule, nil
}