Compare commits

...

8 Commits

Author SHA1 Message Date
文睿 张
102c75079d refactor: 去掉 replaceAllKeys 无人使用的返回值
两处调用点都是裸语句、忽略返回值,函数保持原地修改语义,调用方行为不变。
连带删掉专门断言返回值的那个用例,其余测试不变。
2026-09-21 15:34:45 +08:00
文睿 张
72bc6c9af2 test: 补齐 Update 的 happy-path 用例,锁定「编辑走 UPDATE」而非 INSERT
六个 Update* 此前只有 UpdateRequiresID 覆盖到拒绝分支,方法体里的
s.repo.Update*(item) 从未被执行。这 12 个方法互为复制粘贴的模板,
尾行一旦误写成 Create* 全部测试仍然绿灯,生产行为会退化成「编辑即新增」。

新增两条用例:PartnerService.Update(事务:SELECT ... FOR UPDATE + 两条
UPDATE)与 MasterDataService.UpdateCategory(单条 UPDATE),均登记
UPDATE 期望并以 ExpectationsWereMet 兜底。把尾行改成 Create* 实测两条
用例都会失败并指名收到了 INSERT。

同时 gofmt internal/service/admin_master.go:新增中文注释紧贴前一个函数的
},补上缺失的空行,纯空白改动。
2026-09-21 15:34:41 +08:00
文睿 张
f3124e96cc refactor: 抽出 replaceAllKeys 共享工具,消除两处重复的表单重置 2026-09-21 15:22:23 +08:00
文睿 张
7f19a37591 fix: 新增表单重置为干净状态,不再把上一次编辑的 id 带进请求体 2026-09-21 15:16:22 +08:00
文睿 张
6ed205dcb8 refactor: 拆分 WarehouseService 的 Create/Update,去掉 ID==0 猜分支 2026-09-21 15:09:37 +08:00
文睿 张
16ca9c02ba refactor: 拆分 MasterDataService 的 Create/Update,分类与单位创建不再受脏 id 影响 2026-09-21 15:03:57 +08:00
文睿 张
1b0cdde780 refactor: 拆分 PartnerService 的 Create/Update,创建接口不再被脏 id 带偏 2026-09-21 14:59:01 +08:00
文睿 张
cbcd54b70c fix: 伙伴列表的合作状态列显示中文,不再直接暴露英文枚举值 2026-09-21 14:56:32 +08:00
8 changed files with 493 additions and 51 deletions

View File

@ -0,0 +1,13 @@
// 用 values 的键值对整体替换 target 的内容:先清空 target 已有的全部键,再赋值。
//
// 不能直接用 Object.assign(target, values):assign 只覆盖 target 上**已存在**的键,
// 不会删除多余的键。表单对象上残留的 id / organization_id 这类字段就是这么漏进来的 ——
// values(新建用的空表单)里没有 id,assign 便原样保留了上一次编辑并进来的 id,
// 于是新增请求体里带上了别人的 id。这里先 delete 再赋值,保证结果只含 values 的键。
//
// target 通常是 Vue 的 reactive 对象,delete 与赋值都会触发 Proxy 的拦截,
// 视图能正常同步;同样也适用于普通对象。
export function replaceAllKeys(target, values) {
Object.keys(target).forEach(key => { delete target[key] })
Object.assign(target, values)
}

View File

@ -0,0 +1,45 @@
import { describe, expect, test } from 'vitest'
import { reactive } from 'vue'
import { replaceAllKeys } from './replaceAllKeys'
describe('replaceAllKeys', () => {
test('删掉 values 里没有的键,不留残留字段', () => {
const form = { id: 1, code: 'A', name: '旧名称' }
replaceAllKeys(form, { code: '', name: '' })
expect(form).toEqual({ code: '', name: '' })
expect(Object.keys(form)).not.toContain('id')
})
test('这一点正是 Object.assign 做不到的', () => {
const assigned = { id: 1, code: 'A' }
Object.assign(assigned, { code: 'B' })
expect(assigned.id).toBe(1)
const replaced = { id: 1, code: 'A' }
replaceAllKeys(replaced, { code: 'B' })
expect(replaced.id).toBeUndefined()
})
test('覆盖同名的键并写入新增的键', () => {
const form = { code: 'A', name: '旧名称' }
replaceAllKeys(form, { code: 'A09', name: '新增类', parent_id: null })
expect(form).toEqual({ code: 'A09', name: '新增类', parent_id: null })
})
test('用于 reactive 对象时,键集合与取值都同步更新', () => {
const form = reactive({ id: 7, code: 'A' })
replaceAllKeys(form, { code: 'B', status: 'active' })
expect(Object.keys(form)).toEqual(['code', 'status'])
expect(form.id).toBeUndefined()
expect(form.code).toBe('B')
expect(form.status).toBe('active')
})
})

View File

@ -106,6 +106,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData'
import { replaceAllKeys } from '../../utils/replaceAllKeys'
const tabs = [ const tabs = [
{ key: 'categories', label: '物料分类', api: materialCategoriesApi }, { key: 'categories', label: '物料分类', api: materialCategoriesApi },
@ -152,7 +153,9 @@ async function ensureCategories() {
async function openCreate() { async function openCreate() {
editingId.value = null editingId.value = null
Object.assign(form, defaultForm()) // 用 replaceAllKeys 而不是 Object.assign:openEdit 会把 row 的全部字段并进 form,
// 其中 id / organization_id 在新增时必须清掉,assign 做不到。
replaceAllKeys(form, defaultForm())
if (activeTab.value === 'rules') await ensureCategories() if (activeTab.value === 'rules') await ensureCategories()
dialogVisible.value = true dialogVisible.value = true
} }
@ -166,7 +169,7 @@ async function openEdit(row) {
async function save() { async function save() {
// :rules 在本项目不生效,校验一律手写。编码规则的必填项与其他 tab 不同 // :rules 在本项目不生效,校验一律手写。编码规则的必填项与其他 tab 不同
// (分类 + 前缀,见 SaveCodeRule),所以按 tab 分支。 // (分类 + 前缀,见 prepareCodeRule),所以按 tab 分支。
if (activeTab.value === 'rules') { if (activeTab.value === 'rules') {
if (!form.category_id) { ElMessage.warning('请选择物料分类'); return } if (!form.category_id) { ElMessage.warning('请选择物料分类'); return }
if (!form.prefix.trim()) { ElMessage.warning('请填写编码前缀'); return } if (!form.prefix.trim()) { ElMessage.warning('请填写编码前缀'); return }

View File

@ -123,3 +123,71 @@ describe('伙伴表单必填校验', () => {
wrapper.unmount() wrapper.unmount()
}) })
}) })
describe('伙伴列表的合作状态显示', () => {
beforeEach(() => {
partnersApi.list.mockReset().mockResolvedValue({ data: [] })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('合作状态列显示中文,不把英文枚举值直接给用户', async () => {
partnersApi.list.mockResolvedValue({
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }]
})
const wrapper = mountPartner()
await flushPromises()
expect(wrapper.text()).toContain('启用')
expect(wrapper.text()).not.toContain('active')
wrapper.unmount()
})
test('未收录的状态原样显示,不伪装成已知状态', async () => {
partnersApi.list.mockResolvedValue({
data: [{ id: 2, partner_code: 'SUP-002', legal_name: '某某轴承厂', supplier_status: 'unknown_state' }]
})
const wrapper = mountPartner()
await flushPromises()
expect(wrapper.text()).toContain('unknown_state')
wrapper.unmount()
})
})
describe('新增伙伴不发生表单残留', () => {
beforeEach(() => {
partnersApi.list.mockReset().mockResolvedValue({
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }]
})
partnersApi.create.mockReset().mockResolvedValue({ data: {} })
partnersApi.update.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => { vi.restoreAllMocks() })
test('先编辑再新增时,请求体不能带上一条记录的 id', async () => {
const wrapper = mountPartner()
await flushPromises()
// 先点「编辑」,让表单沾上 id=1
await findButton(wrapper, '编辑').trigger('click')
await flushPromises()
// 关掉弹窗,再点「新增」
await findButton(wrapper, '取消').trigger('click')
await flushPromises()
await openCreateDialog(wrapper, '供应商')
await inputByLabel(wrapper, '伙伴编码').setValue('SUP-009')
await inputByLabel(wrapper, '企业名称').setValue('某某轴承厂')
await clickSave(wrapper)
expect(partnersApi.update).not.toHaveBeenCalled()
expect(partnersApi.create).toHaveBeenCalledTimes(1)
const body = partnersApi.create.mock.calls[0][0]
expect(body.id).toBeUndefined()
wrapper.unmount()
})
})

View File

@ -10,7 +10,7 @@
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" /> <el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
<el-table-column prop="contact_name" label="联系人" width="110" /> <el-table-column prop="contact_name" label="联系人" width="110" />
<el-table-column prop="contact_phone" label="联系电话" width="140" /> <el-table-column prop="contact_phone" label="联系电话" width="140" />
<el-table-column :prop="statusField" label="合作状态" width="110" /> <el-table-column :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" />
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column> <el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column>
</el-table> </el-table>
<el-empty v-if="!loading && rows.length === 0" :description="`暂无${title}`" /> <el-empty v-if="!loading && rows.length === 0" :description="`暂无${title}`" />
@ -25,11 +25,7 @@
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item> <el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="合作状态"> <el-form-item label="合作状态">
<el-select v-model="form.status" style="width: 100%"> <el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" /> <el-option v-for="(label, value) in STATUS_LABELS" :key="value" :label="label" :value="value" />
<el-option label="暂停" value="suspended" />
<el-option label="淘汰" value="eliminated" />
<el-option label="待审核" value="pending" />
<el-option label="停用" value="inactive" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -42,6 +38,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { partnersApi } from '../../api/partners' import { partnersApi } from '../../api/partners'
import { replaceAllKeys } from '../../utils/replaceAllKeys'
const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } }) const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } })
const rows = ref([]) const rows = ref([])
@ -50,11 +47,17 @@ const saving = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref(null) const editingId = ref(null)
const statusField = computed(() => `${props.role}_status`) const statusField = computed(() => `${props.role}_status`)
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' }
// 未收录的状态原样显示,不伪装成某个已知状态。
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
const form = reactive(emptyForm()) const form = reactive(emptyForm())
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } } function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } }
async function load() { loading.value = true; try { const response = await partnersApi.list(props.role); rows.value = response.data || [] } finally { loading.value = false } } async function load() { loading.value = true; try { const response = await partnersApi.list(props.role); rows.value = response.data || [] } finally { loading.value = false } }
function openCreate() { editingId.value = null; Object.assign(form, emptyForm()); dialogVisible.value = true } // 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id,
// 新增时会把别人的 id 一起提交上去。
function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true }
function openEdit(row) { editingId.value = row.id; Object.assign(form, row, { status: row[statusField.value] || 'active' }); dialogVisible.value = true } function openEdit(row) { editingId.value = row.id; Object.assign(form, row, { status: row[statusField.value] || 'active' }); dialogVisible.value = true }
function payload() { function payload() {
return { return {

View File

@ -133,7 +133,7 @@ func (h *Handler) CreateMaterialCategory(c *gin.Context) {
return return
} }
item.OrganizationID = orgID item.OrganizationID = orgID
if err := h.masterDataSvc.SaveCategory(&item); err != nil { if err := h.masterDataSvc.CreateCategory(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -153,7 +153,7 @@ func (h *Handler) UpdateMaterialCategory(c *gin.Context) {
return return
} }
item.ID, item.OrganizationID = id, orgID item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveCategory(&item); err != nil { if err := h.masterDataSvc.UpdateCategory(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -182,7 +182,7 @@ func (h *Handler) CreateUnit(c *gin.Context) {
return return
} }
item.OrganizationID = orgID item.OrganizationID = orgID
if err := h.masterDataSvc.SaveUnit(&item); err != nil { if err := h.masterDataSvc.CreateUnit(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -202,7 +202,7 @@ func (h *Handler) UpdateUnit(c *gin.Context) {
return return
} }
item.ID, item.OrganizationID = id, orgID item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveUnit(&item); err != nil { if err := h.masterDataSvc.UpdateUnit(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -251,7 +251,7 @@ func (h *Handler) CreateCodeRule(c *gin.Context) {
return return
} }
item.OrganizationID = orgID item.OrganizationID = orgID
if err := h.masterDataSvc.SaveCodeRule(&item); err != nil { if err := h.masterDataSvc.CreateCodeRule(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -271,7 +271,7 @@ func (h *Handler) UpdateCodeRule(c *gin.Context) {
return return
} }
item.ID, item.OrganizationID = id, orgID item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveCodeRule(&item); err != nil { if err := h.masterDataSvc.UpdateCodeRule(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -300,7 +300,7 @@ func (h *Handler) CreatePartner(c *gin.Context) {
return return
} }
item.OrganizationID = orgID item.OrganizationID = orgID
if err := h.partnerSvc.Save(&item); err != nil { if err := h.partnerSvc.Create(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -320,7 +320,7 @@ func (h *Handler) UpdatePartner(c *gin.Context) {
return return
} }
item.ID, item.OrganizationID = id, orgID item.ID, item.OrganizationID = id, orgID
if err := h.partnerSvc.Save(&item); err != nil { if err := h.partnerSvc.Update(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -349,7 +349,7 @@ func (h *Handler) CreateWarehouse(c *gin.Context) {
return return
} }
item.OrganizationID = orgID item.OrganizationID = orgID
if err := h.warehouseSvc.Save(&item); err != nil { if err := h.warehouseSvc.Create(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -369,7 +369,7 @@ func (h *Handler) UpdateWarehouse(c *gin.Context) {
return return
} }
item.ID, item.OrganizationID = id, orgID item.ID, item.OrganizationID = id, orgID
if err := h.warehouseSvc.Save(&item); err != nil { if err := h.warehouseSvc.Update(&item); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -389,7 +389,7 @@ func (h *Handler) CreateWarehouseLocation(c *gin.Context) {
return return
} }
item.WarehouseID = warehouseID item.WarehouseID = warehouseID
if err := h.warehouseSvc.SaveLocation(&item, orgID); err != nil { if err := h.warehouseSvc.CreateLocation(&item, orgID); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -414,7 +414,7 @@ func (h *Handler) UpdateWarehouseLocation(c *gin.Context) {
return return
} }
item.ID, item.WarehouseID = locationID, warehouseID item.ID, item.WarehouseID = locationID, warehouseID
if err := h.warehouseSvc.SaveLocation(&item, orgID); err != nil { if err := h.warehouseSvc.UpdateLocation(&item, orgID); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }

View File

@ -9,6 +9,7 @@ import (
) )
var errInvalidMasterData = errors.New("code and name are required") var errInvalidMasterData = errors.New("code and name are required")
var errUpdateRequiresID = errors.New("id is required for update")
var ErrProtectedRole = repository.ErrProtectedRole var ErrProtectedRole = repository.ErrProtectedRole
type OrganizationService struct { type OrganizationService struct {
@ -67,7 +68,29 @@ func NewMasterDataService() *MasterDataService {
func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategory, error) { func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategory, error) {
return s.repo.ListCategories(orgID) return s.repo.ListCategories(orgID)
} }
func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) error {
// CreateCategory 新建分类。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
// repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。
func (s *MasterDataService) CreateCategory(item *model.MaterialCategory) error {
if err := prepareCategory(item); err != nil {
return err
}
return s.repo.CreateCategory(item)
}
// UpdateCategory 更新分类。id 来自路由参数,必须显式存在。
func (s *MasterDataService) UpdateCategory(item *model.MaterialCategory) error {
if item.ID == 0 {
return errUpdateRequiresID
}
if err := prepareCategory(item); err != nil {
return err
}
return s.repo.UpdateCategory(item)
}
// prepareCategory 是新建与更新共用的字段清理、校验和默认值补全。
func prepareCategory(item *model.MaterialCategory) error {
item.Code = strings.TrimSpace(item.Code) item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name) item.Name = strings.TrimSpace(item.Name)
item.Description = strings.TrimSpace(item.Description) item.Description = strings.TrimSpace(item.Description)
@ -77,15 +100,33 @@ func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) error {
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = "active"
} }
if item.ID == 0 { return nil
return s.repo.CreateCategory(item)
}
return s.repo.UpdateCategory(item)
} }
func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) { func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) {
return s.repo.ListUnits(orgID) return s.repo.ListUnits(orgID)
} }
func (s *MasterDataService) SaveUnit(item *model.Unit) error {
// CreateUnit 新建单位。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。
func (s *MasterDataService) CreateUnit(item *model.Unit) error {
if err := prepareUnit(item); err != nil {
return err
}
return s.repo.CreateUnit(item)
}
// UpdateUnit 更新单位。id 来自路由参数,必须显式存在。
func (s *MasterDataService) UpdateUnit(item *model.Unit) error {
if item.ID == 0 {
return errUpdateRequiresID
}
if err := prepareUnit(item); err != nil {
return err
}
return s.repo.UpdateUnit(item)
}
// prepareUnit 是新建与更新共用的字段清理、校验和默认值补全。
func prepareUnit(item *model.Unit) error {
item.Code = strings.TrimSpace(item.Code) item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name) item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" { if item.Code == "" || item.Name == "" {
@ -97,10 +138,7 @@ func (s *MasterDataService) SaveUnit(item *model.Unit) error {
if item.PrecisionScale < 0 || item.PrecisionScale > 6 { if item.PrecisionScale < 0 || item.PrecisionScale > 6 {
return errors.New("precision_scale must be between 0 and 6") return errors.New("precision_scale must be between 0 and 6")
} }
if item.ID == 0 { return nil
return s.repo.CreateUnit(item)
}
return s.repo.UpdateUnit(item)
} }
func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) { func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) {
return s.repo.ListCodeRules(orgID) return s.repo.ListCodeRules(orgID)
@ -118,7 +156,28 @@ func (s *MasterDataService) NextMaterialCode(orgID, categoryID int64) (*model.Ne
} }
return &model.NextMaterialCode{Available: true, Code: rule.FormatCode(rule.NextSequence)}, nil return &model.NextMaterialCode{Available: true, Code: rule.FormatCode(rule.NextSequence)}, nil
} }
func (s *MasterDataService) SaveCodeRule(item *model.CodeRule) error {
// 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) item.Prefix = strings.TrimSpace(item.Prefix)
if item.CategoryID <= 0 || item.Prefix == "" { if item.CategoryID <= 0 || item.Prefix == "" {
return errors.New("category_id and prefix are required") return errors.New("category_id and prefix are required")
@ -132,10 +191,7 @@ func (s *MasterDataService) SaveCodeRule(item *model.CodeRule) error {
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = "active"
} }
if item.ID == 0 { return nil
return s.repo.CreateCodeRule(item)
}
return s.repo.UpdateCodeRule(item)
} }
type PartnerService struct{ repo *repository.PartnerRepository } type PartnerService struct{ repo *repository.PartnerRepository }
@ -149,7 +205,29 @@ func (s *PartnerService) List(orgID int64, role string) ([]model.BusinessPartner
} }
return s.repo.List(orgID, role) return s.repo.List(orgID, role)
} }
func (s *PartnerService) Save(item *model.BusinessPartner) error {
// Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
// repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。
func (s *PartnerService) Create(item *model.BusinessPartner) error {
if err := preparePartner(item); err != nil {
return err
}
return s.repo.Create(item)
}
// Update 更新伙伴。id 来自路由参数,必须显式存在。
func (s *PartnerService) Update(item *model.BusinessPartner) error {
if item.ID == 0 {
return errUpdateRequiresID
}
if err := preparePartner(item); err != nil {
return err
}
return s.repo.Update(item)
}
// preparePartner 是新建与更新共用的字段清理、校验和默认值补全。
func preparePartner(item *model.BusinessPartner) error {
item.PartnerCode = strings.TrimSpace(item.PartnerCode) item.PartnerCode = strings.TrimSpace(item.PartnerCode)
item.LegalName = strings.TrimSpace(item.LegalName) item.LegalName = strings.TrimSpace(item.LegalName)
if item.PartnerCode == "" || item.LegalName == "" { if item.PartnerCode == "" || item.LegalName == "" {
@ -164,10 +242,7 @@ func (s *PartnerService) Save(item *model.BusinessPartner) error {
if item.IsCustomer && item.CustomerStatus == "" { if item.IsCustomer && item.CustomerStatus == "" {
item.CustomerStatus = "active" item.CustomerStatus = "active"
} }
if item.ID == 0 { return nil
return s.repo.Create(item)
}
return s.repo.Update(item)
} }
type WarehouseService struct { type WarehouseService struct {
@ -180,7 +255,28 @@ func NewWarehouseService() *WarehouseService {
func (s *WarehouseService) List(orgID int64) ([]model.Warehouse, error) { func (s *WarehouseService) List(orgID int64) ([]model.Warehouse, error) {
return s.repo.List(orgID) return s.repo.List(orgID)
} }
func (s *WarehouseService) Save(item *model.Warehouse) error {
// Create 新建仓库。不读取 item.ID,脏 id 不会把新建变成覆盖。
func (s *WarehouseService) Create(item *model.Warehouse) error {
if err := prepareWarehouse(item); err != nil {
return err
}
return s.repo.Create(item)
}
// Update 更新仓库。id 来自路由参数,必须显式存在。
func (s *WarehouseService) Update(item *model.Warehouse) error {
if item.ID == 0 {
return errUpdateRequiresID
}
if err := prepareWarehouse(item); err != nil {
return err
}
return s.repo.Update(item)
}
// prepareWarehouse 是新建与更新共用的字段清理、校验和默认值补全。
func prepareWarehouse(item *model.Warehouse) error {
item.Code = strings.TrimSpace(item.Code) item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name) item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" { if item.Code == "" || item.Name == "" {
@ -189,12 +285,30 @@ func (s *WarehouseService) Save(item *model.Warehouse) error {
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = "active"
} }
if item.ID == 0 { return nil
return s.repo.Create(item)
}
return s.repo.Update(item)
} }
func (s *WarehouseService) SaveLocation(item *model.WarehouseLocation, orgID int64) error {
// CreateLocation 新建库位。orgID 用于校验仓库归属,item.ID 不参与。
func (s *WarehouseService) CreateLocation(item *model.WarehouseLocation, orgID int64) error {
if err := prepareLocation(item); err != nil {
return err
}
return s.repo.CreateLocation(item, orgID)
}
// UpdateLocation 更新库位。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
}
return s.repo.UpdateLocation(item, orgID)
}
// prepareLocation 是新建与更新共用的字段清理、校验和默认值补全。
func prepareLocation(item *model.WarehouseLocation) error {
item.Code = strings.TrimSpace(item.Code) item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name) item.Name = strings.TrimSpace(item.Name)
if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 { if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 {
@ -203,8 +317,5 @@ func (s *WarehouseService) SaveLocation(item *model.WarehouseLocation, orgID int
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = "active"
} }
if item.ID == 0 { return nil
return s.repo.CreateLocation(item, orgID)
}
return s.repo.UpdateLocation(item, orgID)
} }

View File

@ -0,0 +1,199 @@
package service
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
// 前端表单在「新增」时可能残留上次编辑过的 id(PartnerList.vue 的
// openCreate 只做 Object.assign、不删多余键)。创建必须无视这个 id,
// 否则会走成 UPDATE 把旧记录覆盖掉——这正是本次要修的缺陷。
func TestPartnerCreateInsertsEvenWhenItemCarriesStaleID(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.ExpectBegin()
mock.ExpectExec("INSERT INTO business_partners").WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("INSERT INTO organization_partners").WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectCommit()
item := &model.BusinessPartner{ID: 1, PartnerCode: "SUP-002", LegalName: "新伙伴", IsSupplier: true}
if err := NewPartnerService().Create(item); err != nil {
t.Fatalf("Create: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
// 更新接口漏传 id 时必须报错,否则会静默写 0 号记录(什么都不改)而不自知。
func TestPartnerUpdateRequiresID(t *testing.T) {
err := NewPartnerService().Update(&model.BusinessPartner{PartnerCode: "SUP-001", LegalName: "伙伴", IsSupplier: true})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
// 拆方法不能把校验丢了。
func TestPartnerCreateStillValidatesName(t *testing.T) {
err := NewPartnerService().Create(&model.BusinessPartner{PartnerCode: "SUP-001", IsSupplier: true})
if err == nil {
t.Fatal("legal_name 为空应当被拒绝")
}
}
func TestPartnerCreateStillRequiresARole(t *testing.T) {
err := NewPartnerService().Create(&model.BusinessPartner{PartnerCode: "SUP-001", LegalName: "伙伴"})
if err == nil {
t.Fatal("既非供应商也非客户时应当被拒绝")
}
}
// MasterDataSettings.vue 的 openCreate 同样是 Object.assign 复用表单,
// 分类新增也会带上前一次编辑残留的 id,必须走 INSERT。
func TestCategoryCreateInsertsEvenWhenItemCarriesStaleID(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("INSERT INTO material_categories").WillReturnResult(sqlmock.NewResult(9, 1))
item := &model.MaterialCategory{ID: 1, Code: "A99", Name: "新分类"}
if err := NewMasterDataService().CreateCategory(item); err != nil {
t.Fatalf("CreateCategory: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
func TestUnitUpdateRequiresID(t *testing.T) {
err := NewMasterDataService().UpdateUnit(&model.Unit{Code: "pcs", Name: "件"})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
func TestCodeRuleUpdateRequiresID(t *testing.T) {
err := NewMasterDataService().UpdateCodeRule(&model.CodeRule{CategoryID: 1, Prefix: "A", SequenceLength: 4})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
// 单位的小数位校验不能因为拆方法而丢失。
func TestUnitCreateStillValidatesPrecisionScale(t *testing.T) {
err := NewMasterDataService().CreateUnit(&model.Unit{Code: "kg", Name: "千克", PrecisionScale: 9})
if err == nil {
t.Fatal("precision_scale 超过 6 应当被拒绝")
}
}
func TestWarehouseCreateInsertsEvenWhenItemCarriesStaleID(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("INSERT INTO warehouses").WillReturnResult(sqlmock.NewResult(5, 1))
item := &model.Warehouse{ID: 3, Code: "WH2", Name: "二号仓"}
if err := NewWarehouseService().Create(item); err != nil {
t.Fatalf("Create: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
func TestWarehouseUpdateRequiresID(t *testing.T) {
err := NewWarehouseService().Update(&model.Warehouse{Code: "WH1", Name: "一号仓"})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
func TestLocationCreateRequiresWarehouseID(t *testing.T) {
err := NewWarehouseService().CreateLocation(&model.WarehouseLocation{Code: "L1", Name: "库位一"}, 1)
if err == nil {
t.Fatal("缺少 warehouse_id 的库位创建应当被拒绝")
}
}
// 上面的 UpdateRequiresID 只覆盖了拒绝分支,方法体里真正的 s.repo.Update 从未被执行。
// 六个 Update* 互为复制粘贴的模板,若某一处尾行被误写成 s.repo.Create,
// 生产行为会退化成「编辑即新增」——与本次修复的缺陷同级。这里补上走通的路径。
func TestPartnerUpdateUpdatesInsteadOfInserting(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.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,
PartnerCode: "SUP-007",
LegalName: "伙伴改名",
IsSupplier: true,
}
if err := NewPartnerService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
// 只登记了 SELECT ... FOR UPDATE 与两条 UPDATE。若尾行被误写成 Create,
// 事务里发出的 INSERT 与这里的期望不匹配:要么 Create 直接报错、要么
// 这两条 UPDATE 永不被消费,ExpectationsWereMet 必然失败。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestCategoryUpdateUpdatesInsteadOfInserting(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 material_categories").
WithArgs("改名后的分类", "说明", nil, "active", int64(9), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.MaterialCategory{
ID: 9,
OrganizationID: 1,
Code: "A01",
Name: "改名后的分类",
Description: "说明",
}
if err := NewMasterDataService().UpdateCategory(item); err != nil {
t.Fatalf("UpdateCategory: %v", err)
}
// 这里只登记了 UPDATE;误写成 Create 时发出的 INSERT 不会被消费,
// Expectation 落空,ExpectationsWereMet 报错。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是插入: %v", err)
}
}