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 拼接;
全部做过变异验证(抽掉实现后对应测试确实变红)。
This commit is contained in:
文睿 张 2026-09-21 17:26:53 +08:00
parent 837cc5787b
commit 696e90893b
10 changed files with 595 additions and 65 deletions

View File

@ -2,11 +2,27 @@ 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{} }
@ -38,8 +54,7 @@ func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) erro
}
func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error {
_, err := database.DB.Exec(`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)
return err
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) {
@ -69,8 +84,7 @@ func (r *MasterDataRepository) CreateUnit(item *model.Unit) error {
}
func (r *MasterDataRepository) UpdateUnit(item *model.Unit) error {
_, err := database.DB.Exec(`UPDATE units SET name = ?, precision_scale = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.PrecisionScale, item.Status, item.ID, item.OrganizationID)
return err
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) {
@ -95,15 +109,17 @@ func (r *MasterDataRepository) ListCodeRules(organizationID int64) ([]model.Code
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 err
return translateDuplicateCodeRule(err)
}
item.ID, err = result.LastInsertId()
return err
}
// UpdateCodeRule 更新编码规则。category_id 必须写进 SET:前端编辑弹窗里的
// 分类下拉没有禁用(与同页的编码输入框不同),而规则又只能改不能删,
// 漏掉这一列会让"改分类"提示成功却完全不落库。
func (r *MasterDataRepository) UpdateCodeRule(item *model.CodeRule) error {
_, err := database.DB.Exec(`UPDATE code_rules SET prefix = ?, sequence_length = ?, next_sequence = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Prefix, item.SequenceLength, item.NextSequence, item.Status, item.ID, item.OrganizationID)
return err
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)。

View File

@ -44,8 +44,7 @@ func (r *OrganizationRepository) Create(item *model.Organization) error {
}
func (r *OrganizationRepository) Update(item *model.Organization) error {
_, err := database.DB.Exec(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID)
return err
return execExpectingRow(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID)
}
func (r *OrganizationRepository) ListRoles() ([]model.Role, error) {

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
@ -92,3 +93,21 @@ func TestUpdateRoleReplacesPermissionsInTransaction(t *testing.T) {
t.Fatal(err)
}
}
// 组织不存在时(比如 id 打错)更新影响 0 行。此前返回 nil,接口提示"保存成功"
// 而实际什么都没改。
func TestOrganizationUpdateReportsMissingRecord(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() { database.DB = nil; db.Close() })
database.DB = db
mock.ExpectExec("UPDATE organizations").WillReturnResult(sqlmock.NewResult(0, 0))
err = NewOrganizationRepository().Update(&model.Organization{ID: 404, Name: "改名后的组织"})
if !errors.Is(err, ErrRecordNotFound) {
t.Fatalf("影响 0 行应当返回 ErrRecordNotFound,实际 %v", err)
}
}

View File

@ -1,6 +1,8 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"github.com/wmms/wmms/internal/model"
@ -110,8 +112,13 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
}
}()
// 这一句兼任存在性检查:查不到就说明伙伴不存在或不属于当前组织,
// 不能让它以 sql.ErrNoRows 一路冒到接口变成 500。
var businessPartnerID int64
if err = tx.QueryRow(`SELECT business_partner_id FROM organization_partners WHERE id = ? AND organization_id = ? FOR UPDATE`, item.ID, item.OrganizationID).Scan(&businessPartnerID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrRecordNotFound
}
return err
}
if _, err = tx.Exec(`UPDATE business_partners SET legal_name = ?, unified_credit_code = NULLIF(?, ''),

View File

@ -0,0 +1,32 @@
package repository
import (
"errors"
"github.com/wmms/wmms/pkg/database"
)
// ErrRecordNotFound 表示按 id + 组织没查到目标记录:要么 id 不存在,要么这条记录
// 属于别的组织。按主键更新时影响 0 行就返回它,避免「改了一条不存在的记录」还提示
// 保存成功。
var ErrRecordNotFound = errors.New("记录不存在,可能已被删除或不属于当前组织")
// execExpectingRow 执行一条按主键更新的语句,并要求它至少匹配到一行。
//
// 判断依据是 RowsAffected,而 MySQL 默认返回的是「实际改变」的行数——编辑弹窗里
// 一个字段都没动时同样是 0,跟记录不存在分不开。所以 DSN 必须开 clientFoundRows=true
// (见 pkg/database.Init)把语义换成「匹配」的行数,这个检查才成立。
func execExpectingRow(query string, args ...interface{}) error {
result, err := database.DB.Exec(query, args...)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrRecordNotFound
}
return nil
}

View File

@ -54,8 +54,7 @@ func (r *WarehouseRepository) Create(item *model.Warehouse) error {
}
func (r *WarehouseRepository) Update(item *model.Warehouse) error {
_, err := database.DB.Exec(`UPDATE warehouses SET name = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Status, item.ID, item.OrganizationID)
return err
return execExpectingRow(`UPDATE warehouses SET name = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Status, item.ID, item.OrganizationID)
}
func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error {
@ -69,7 +68,8 @@ func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, orga
}
func (r *WarehouseRepository) UpdateLocation(item *model.WarehouseLocation, organizationID int64) error {
_, err := database.DB.Exec(`UPDATE warehouse_locations l JOIN warehouses w ON w.id = l.warehouse_id
// JOIN 形式的 UPDATE 同样受 clientFoundRows 影响,影响 0 行也代表库位不存在
// 或不属于该组织(已实测)。
return execExpectingRow(`UPDATE warehouse_locations l JOIN warehouses w ON w.id = l.warehouse_id
SET l.name = ?, l.status = ? WHERE l.id = ? AND w.organization_id = ?`, item.Name, item.Status, item.ID, organizationID)
return err
}

View File

@ -8,6 +8,13 @@ import (
"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
@ -72,35 +79,35 @@ func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategor
// CreateCategory 新建分类。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
// repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。
func (s *MasterDataService) CreateCategory(item *model.MaterialCategory) error {
if err := prepareCategory(item); err != nil {
return err
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
}
if err := prepareCategory(item); err != nil {
return err
normalizeCategory(item)
if item.Name == "" {
return errInvalidMasterData
}
return s.repo.UpdateCategory(item)
}
// prepareCategory 是新建与更新共用的字段清理、校验和默认值补全。
func prepareCategory(item *model.MaterialCategory) error {
// 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.Code == "" || item.Name == "" {
return errInvalidMasterData
}
if item.Status == "" {
item.Status = "active"
}
return nil
}
func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) {
return s.repo.ListUnits(orgID)
@ -108,30 +115,35 @@ func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) {
// CreateUnit 新建单位。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。
func (s *MasterDataService) CreateUnit(item *model.Unit) error {
if err := prepareUnit(item); err != nil {
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 := prepareUnit(item); err != nil {
if err := normalizeUnit(item); err != nil {
return err
}
if item.Name == "" {
return errInvalidMasterData
}
return s.repo.UpdateUnit(item)
}
// prepareUnit 是新建与更新共用的字段清理、校验和默认值补全。
func prepareUnit(item *model.Unit) error {
// normalizeUnit 是新建与更新共用的字段清理、默认值补全和小数位值域校验。
// 小数位在编辑时也能改,所以两个入口都要查。编码是否必填由两个入口各自判断。
func normalizeUnit(item *model.Unit) error {
item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" {
return errInvalidMasterData
}
if item.Status == "" {
item.Status = "active"
}
@ -209,30 +221,37 @@ func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]mo
// Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
// repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。
func (s *PartnerService) Create(item *model.BusinessPartner) error {
if err := preparePartner(item); err != nil {
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 := preparePartner(item); err != nil {
if err := normalizePartner(item); err != nil {
return err
}
if item.LegalName == "" {
return errors.New("legal_name is required")
}
return s.repo.Update(item)
}
// preparePartner 是新建与更新共用的字段清理、校验和默认值补全。
func preparePartner(item *model.BusinessPartner) error {
// normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验:
// 至少要有供应商或客户其中一个角色,编辑时也可能把两个都取消掉。
// 编码是否必填由两个入口各自判断。
func normalizePartner(item *model.BusinessPartner) error {
item.PartnerCode = strings.TrimSpace(item.PartnerCode)
item.LegalName = strings.TrimSpace(item.LegalName)
if item.PartnerCode == "" || item.LegalName == "" {
return errors.New("partner_code and legal_name are required")
}
if !item.IsSupplier && !item.IsCustomer {
return errors.New("at least one partner role is required")
}
@ -258,64 +277,64 @@ func (s *WarehouseService) List(orgID int64) ([]model.Warehouse, error) {
// Create 新建仓库。不读取 item.ID,脏 id 不会把新建变成覆盖。
func (s *WarehouseService) Create(item *model.Warehouse) error {
if err := prepareWarehouse(item); err != nil {
return err
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
}
if err := prepareWarehouse(item); err != nil {
return err
normalizeWarehouse(item)
if item.Name == "" {
return errInvalidMasterData
}
return s.repo.Update(item)
}
// prepareWarehouse 是新建与更新共用的字段清理、校验和默认值补全。
func prepareWarehouse(item *model.Warehouse) error {
// normalizeWarehouse 是新建与更新共用的字段清理和默认值补全。
// 编码是否必填由两个入口各自判断:新建必填,更新不看。
func normalizeWarehouse(item *model.Warehouse) {
item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" {
return errInvalidMasterData
}
if item.Status == "" {
item.Status = "active"
}
return nil
}
// CreateLocation 新建库位。orgID 用于校验仓库归属,item.ID 不参与。
func (s *WarehouseService) CreateLocation(item *model.WarehouseLocation, orgID int64) error {
if err := prepareLocation(item); err != nil {
return err
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
}
if err := prepareLocation(item); err != nil {
return err
normalizeLocation(item)
if item.Name == "" || item.WarehouseID <= 0 {
return errInvalidMasterData
}
return s.repo.UpdateLocation(item, orgID)
}
// prepareLocation 是新建与更新共用的字段清理、校验和默认值补全。
func prepareLocation(item *model.WarehouseLocation) error {
// normalizeLocation 是新建与更新共用的字段清理和默认值补全。
// 编码是否必填由两个入口各自判断:新建必填,更新不看。
func normalizeLocation(item *model.WarehouseLocation) {
item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 {
return errInvalidMasterData
}
if item.Status == "" {
item.Status = "active"
}
return nil
}

View File

@ -1,10 +1,13 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
)
@ -135,6 +138,8 @@ func TestLocationCreateRequiresWarehouseID(t *testing.T) {
// 上面的 UpdateRequiresID 只覆盖了拒绝分支,方法体里真正的 s.repo.Update 从未被执行。
// 六个 Update* 互为复制粘贴的模板,若某一处尾行被误写成 s.repo.Create,
// 生产行为会退化成「编辑即新增」——与本次修复的缺陷同级。这里补上走通的路径。
// Partner / Category / Unit / CodeRule / Warehouse / Location 六个现已全覆盖,
// 且逐个把尾行改成 Create* 验证过确实会被抓住,不是只跑绿。
func TestPartnerUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@ -197,3 +202,356 @@ func TestCategoryUpdateUpdatesInsteadOfInserting(t *testing.T) {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是插入: %v", err)
}
}
func TestUnitUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE units").
WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Unit{
ID: 4,
OrganizationID: 1,
Code: "pcs",
Name: "件(改名)",
PrecisionScale: 2,
}
if err := NewMasterDataService().UpdateUnit(item); err != nil {
t.Fatalf("UpdateUnit: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestCodeRuleUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
// category_id 必须出现在参数里:SET 漏掉它正是本次修的缺陷,
// 这里用 WithArgs 钉住参数个数和顺序,漏了或写错位置都会失败。
mock.ExpectExec("UPDATE code_rules").
WithArgs(int64(2), "B", int64(5), int64(7), "active", int64(3), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.CodeRule{
ID: 3,
OrganizationID: 1,
CategoryID: 2,
Prefix: "B",
SequenceLength: 5,
NextSequence: 7,
}
if err := NewMasterDataService().UpdateCodeRule(item); err != nil {
t.Fatalf("UpdateCodeRule: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestWarehouseUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE warehouses").
WithArgs("一号仓(改名)", "active", int64(5), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Warehouse{
ID: 5,
OrganizationID: 1,
Code: "WH1",
Name: "一号仓(改名)",
}
if err := NewWarehouseService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
// 库位这条和上面三条不同:repository 的 UPDATE 是 JOIN warehouses 的写法,
// orgID 由方法参数传入、不在 item 上,SQL 模式写窄一点才能对上。
func TestLocationUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE warehouse_locations").
WithArgs("库位一(改名)", "active", int64(8), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.WarehouseLocation{
ID: 8,
WarehouseID: 3,
Code: "L1",
Name: "库位一(改名)",
}
if err := NewWarehouseService().UpdateLocation(item, 1); err != nil {
t.Fatalf("UpdateLocation: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
// 以下几组测试钉住「编码不可变」这条契约的两端:
// 更新侧——请求里不带 code 也必须能走通。旧实现复用 Create 的校验,要求 code
// 非空,而 repository 的 UPDATE 又不写这一列,形成「必须传、传了却被丢弃」。
// 新建侧——拆出 normalize* 时不能把 code 的必填校验一起丢了。
func newMasterDataMock(t *testing.T) sqlmock.Sqlmock {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
return mock
}
func TestCategoryUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE material_categories").
WithArgs("只改名称", "", nil, "active", int64(9), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.MaterialCategory{ID: 9, OrganizationID: 1, Name: "只改名称"}
if err := NewMasterDataService().UpdateCategory(item); err != nil {
t.Fatalf("UpdateCategory: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestUnitUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE units").
WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Unit{ID: 4, OrganizationID: 1, Name: "件(改名)", PrecisionScale: 2}
if err := NewMasterDataService().UpdateUnit(item); err != nil {
t.Fatalf("UpdateUnit: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestWarehouseUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE warehouses").
WithArgs("一号仓(改名)", "active", int64(5), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Warehouse{ID: 5, OrganizationID: 1, Name: "一号仓(改名)"}
if err := NewWarehouseService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestLocationUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE warehouse_locations").
WithArgs("库位一(改名)", "active", int64(8), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.WarehouseLocation{ID: 8, WarehouseID: 3, Name: "库位一(改名)"}
if err := NewWarehouseService().UpdateLocation(item, 1); err != nil {
t.Fatalf("UpdateLocation: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestPartnerUpdateAcceptsRequestWithoutPartnerCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT business_partner_id FROM organization_partners").
WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(int64(7)))
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE organization_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
item := &model.BusinessPartner{ID: 12, OrganizationID: 1, LegalName: "伙伴改名", IsSupplier: true}
if err := NewPartnerService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 partner_code 的更新应当照常执行: %v", err)
}
}
// 更新侧放开 code 之后,名称这类可改字段的校验不能跟着一起没了。
func TestUpdatesStillRequireName(t *testing.T) {
cases := []struct {
name string
run func() error
}{
{"category", func() error {
return NewMasterDataService().UpdateCategory(&model.MaterialCategory{ID: 9, Code: "A01"})
}},
{"unit", func() error {
return NewMasterDataService().UpdateUnit(&model.Unit{ID: 4, Code: "pcs"})
}},
{"warehouse", func() error {
return NewWarehouseService().Update(&model.Warehouse{ID: 5, Code: "WH1"})
}},
{"location", func() error {
return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 8, WarehouseID: 3, Code: "L1"}, 1)
}},
{"partner", func() error {
return NewPartnerService().Update(&model.BusinessPartner{ID: 12, PartnerCode: "SUP-007", IsSupplier: true})
}},
}
for _, c := range cases {
if err := c.run(); err == nil {
t.Errorf("%s: 名称为空的更新应当被拒绝", c.name)
}
}
}
// 新建侧仍然要求 code:拆方法不能把必填校验弄丢。
func TestCreatesStillRequireCode(t *testing.T) {
cases := []struct {
name string
run func() error
}{
{"category", func() error {
return NewMasterDataService().CreateCategory(&model.MaterialCategory{Name: "新分类"})
}},
{"unit", func() error {
return NewMasterDataService().CreateUnit(&model.Unit{Name: "件"})
}},
{"warehouse", func() error {
return NewWarehouseService().Create(&model.Warehouse{Name: "二号仓"})
}},
{"location", func() error {
return NewWarehouseService().CreateLocation(&model.WarehouseLocation{WarehouseID: 3, Name: "库位一"}, 1)
}},
{"partner", func() error {
return NewPartnerService().Create(&model.BusinessPartner{LegalName: "新伙伴", IsSupplier: true})
}},
}
for _, c := range cases {
if err := c.run(); err == nil {
t.Errorf("%s: 缺少 code 的新建应当被拒绝", c.name)
}
}
}
// 修好 category_id 之后,把规则改到已有规则的分类上会撞
// uk_code_rules_org_category。此前 SET 不写这一列,永远撞不上;
// 现在必须把这冲突翻成中文提示,否则前端只会显示「请求失败」。
func TestCodeRuleUpdateReportsDuplicateCategory(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE code_rules").
WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'"))
item := &model.CodeRule{ID: 3, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1}
err := NewMasterDataService().UpdateCodeRule(item)
if !errors.Is(err, repository.ErrDuplicateCodeRule) {
t.Fatalf("撞唯一键应当翻成 ErrDuplicateCodeRule,实际 %v", err)
}
// 前端 index.js 只把中文消息照原样弹出,英文一律显示"请求失败"。
if !strings.Contains(err.Error(), "已有编码规则") {
t.Fatalf("错误消息应当是中文,实际 %q", err.Error())
}
}
func TestCodeRuleCreateReportsDuplicateCategory(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("INSERT INTO code_rules").
WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'"))
item := &model.CodeRule{OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1}
if err := NewMasterDataService().CreateCodeRule(item); !errors.Is(err, repository.ErrDuplicateCodeRule) {
t.Fatalf("新建撞唯一键也应当翻成 ErrDuplicateCodeRule,实际 %v", err)
}
}
// 记录不存在、或者存在但不属于当前组织时,UPDATE 影响 0 行。此前一律返回 nil,
// 接口提示"保存成功"而实际什么都没改。
//
// 这条检查依赖 DSN 的 clientFoundRows=true:不开的话「编辑弹窗里一个字段都没动」
// 同样是 0 行,正常的保存会被误判成记录不存在。开关的实测数据见
// pkg/database/database.go 里 normalizeDSN 的注释。
func TestUpdatesReportMissingRecord(t *testing.T) {
cases := []struct {
name string
sql string
run func() error
}{
{"category", "UPDATE material_categories", func() error {
return NewMasterDataService().UpdateCategory(&model.MaterialCategory{ID: 404, OrganizationID: 1, Name: "改名"})
}},
{"unit", "UPDATE units", func() error {
return NewMasterDataService().UpdateUnit(&model.Unit{ID: 404, OrganizationID: 1, Name: "件"})
}},
{"code rule", "UPDATE code_rules", func() error {
return NewMasterDataService().UpdateCodeRule(&model.CodeRule{
ID: 404, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1,
})
}},
{"warehouse", "UPDATE warehouses", func() error {
return NewWarehouseService().Update(&model.Warehouse{ID: 404, OrganizationID: 1, Name: "一号仓"})
}},
{"location", "UPDATE warehouse_locations", func() error {
return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 404, WarehouseID: 3, Name: "库位一"}, 1)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
mock := newMasterDataMock(t)
// 第二个参数是受影响行数:0 行代表 id 不存在或组织对不上。
mock.ExpectExec(c.sql).WillReturnResult(sqlmock.NewResult(0, 0))
if err := c.run(); !errors.Is(err, repository.ErrRecordNotFound) {
t.Fatalf("影响 0 行应当返回 ErrRecordNotFound,实际 %v", err)
}
})
}
}
// 伙伴走的是另一条路:事务里先 SELECT ... FOR UPDATE,查不到就已经能判定不存在。
// 不翻译的话会以 sql.ErrNoRows 冒到接口,变成 500 加一句英文。
func TestPartnerUpdateReportsMissingRecord(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT business_partner_id FROM organization_partners").
WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}))
mock.ExpectRollback()
item := &model.BusinessPartner{ID: 404, OrganizationID: 1, LegalName: "不存在的伙伴", IsSupplier: true}
if err := NewPartnerService().Update(item); !errors.Is(err, repository.ErrRecordNotFound) {
t.Fatalf("查不到伙伴应当返回 ErrRecordNotFound,实际 %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("查不到时应当回滚事务: %v", err)
}
}

View File

@ -11,15 +11,31 @@ import (
var DB *sql.DB
func Init(dsn string) error {
// Add parseTime=true to handle DATETIME columns as time.Time
if !strings.Contains(dsn, "parseTime=") {
if strings.Contains(dsn, "?") {
dsn += "&parseTime=true"
} else {
dsn += "?parseTime=true"
}
// appendDSNParam 往 DSN 里补一个连接参数。key 已经出现过就原样返回——调用方
// 显式配的值优先,哪怕配的是反值。
func appendDSNParam(dsn, key, value string) string {
if strings.Contains(dsn, key+"=") {
return dsn
}
if strings.Contains(dsn, "?") {
return dsn + "&" + key + "=" + value
}
return dsn + "?" + key + "=" + value
}
// normalizeDSN 补全连接参数。这两个都在代码里兜底,不要求每个部署环境都记得写:
// - parseTime=true 把 DATETIME 列扫成 time.Time;
// - clientFoundRows=true 让 UPDATE 的 RowsAffected 返回「匹配」的行数,而不是
// MySQL 默认的「实际改变」的行数。默认语义下「编辑时一个字段都没改」和
// 「记录不存在」都返回 0,主数据的 Update* 就没法据此判断记录是否存在;
// 开启后两者分别是 1 和 0,可以区分。对仓库/库位的 JOIN 形式 UPDATE 同样有效。
func normalizeDSN(dsn string) string {
dsn = appendDSNParam(dsn, "parseTime", "true")
return appendDSNParam(dsn, "clientFoundRows", "true")
}
func Init(dsn string) error {
dsn = normalizeDSN(dsn)
var err error
DB, err = sql.Open("mysql", dsn)

View File

@ -0,0 +1,64 @@
package database
import "testing"
// Init 靠 appendDSNParam 把 parseTime 和 clientFoundRows 补进 DSN。这里钉住拼接
// 规则:没有 ? 要用 ? 起头,已有 ? 要用 & 续接,已经写过的参数不再重复追加。
func TestAppendDSNParam(t *testing.T) {
cases := []struct {
name string
dsn string
key string
value string
want string
}{
{
name: "没有查询串时用问号起头",
dsn: "root:pw@tcp(db:3306)/wmms",
key: "parseTime",
value: "true",
want: "root:pw@tcp(db:3306)/wmms?parseTime=true",
},
{
name: "已有查询串时用与号续接",
dsn: "root:pw@tcp(db:3306)/wmms?charset=utf8mb4",
key: "parseTime",
value: "true",
want: "root:pw@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true",
},
{
name: "已经配过就不重复追加",
dsn: "root:pw@tcp(db:3306)/wmms?parseTime=true",
key: "parseTime",
value: "true",
want: "root:pw@tcp(db:3306)/wmms?parseTime=true",
},
{
// 按参数名判断,不按完整参数值:调用方显式关了 clientFoundRows,
// 不能被当成"没配过"再追加一个 true 进去。
name: "显式配置的反值优先",
dsn: "root:pw@tcp(db:3306)/wmms?clientFoundRows=false",
key: "clientFoundRows",
value: "true",
want: "root:pw@tcp(db:3306)/wmms?clientFoundRows=false",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := appendDSNParam(c.dsn, c.key, c.value); got != c.want {
t.Errorf("appendDSNParam(%q, %q, %q)\n got = %q\nwant = %q", c.dsn, c.key, c.value, got, c.want)
}
})
}
}
// Init 实际走的就是这条路径。两个参数缺一不可:漏了 clientFoundRows,Update* 的
// 存在性检查会把「编辑时一个字段都没改」误判成「记录不存在」,成了比原缺陷更糟的
// 回归——正常的保存会被拒绝。
func TestNormalizeDSNAddsBothParams(t *testing.T) {
got := normalizeDSN("root:pw@tcp(db:3306)/wmms?charset=utf8mb4")
want := "root:pw@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true&clientFoundRows=true"
if got != want {
t.Fatalf("normalizeDSN()\n got = %q\nwant = %q", got, want)
}
}