Compare commits

...

2 Commits

Author SHA1 Message Date
文睿 张
5a0376f318 拆分主数据和仓库数据 2026-09-16 16:32:02 +08:00
文睿 张
a103549854 拆分主数据和仓库数据 2026-09-16 16:29:04 +08:00
57 changed files with 3297 additions and 7 deletions

16
.gitignore vendored
View File

@ -1,7 +1,9 @@
".claude/"
".gocache/"
".superpowers/"
"node_modules/"
"*.exe~"
"nul"
"server.exe"
.claude/
.gocache/
.superpowers/
node_modules/
*.exe~
nul
server.exe
warehouse-mms.exe
frontend/dist/

31
docs/phase1-migration.md Normal file
View File

@ -0,0 +1,31 @@
# 第一阶段数据库迁移
本阶段将公司组织、业务伙伴、仓库/库位、物料基础配置和库存余额拆分到独立表。旧的
`materials.current_stock` 与 `materials.warning_stock` 暂时保留用于回滚观察,但应用代码不再读写。
## 执行顺序
1. 备份现有数据库,并暂停入库、出库和物料申请操作。
2. 为当前 shell 设置 `DB_DSN`,不要把账号密码写入仓库。
3. 在项目根目录运行:
```powershell
go run ./cmd/migrate
```
4. 确认迁移 `001` 到 `006` 均为非 dirty 状态,并检查 `006` 最后的库存差异查询没有结果。
5. 发布后端和前端,再恢复库存操作。
迁移器按文件名顺序执行并记录在 `schema_migrations`。MySQL DDL 可能隐式提交;如果某个版本为
dirty,迁移器会停止,必须根据报错的语句编号人工核对并修复,不能直接假定事务已回滚。
## 核对重点
- 每个旧用户均已分配默认组织和默认仓库。
- 旧物料数量与 `inventory_balances.on_hand` 汇总一致。
- 旧预警值与 `stock_warning_rules.minimum_available` 一致。
- `materials` API 响应不再包含库存数量和预警值。
- 公司组织位于 `organizations`,外部公司位于 `business_partners`,合作关系位于 `organization_partners`。
不要在生产库直接试跑。应先在数据库副本验证一次;当前自动化测试只验证迁移执行器和应用查询,
不替代生产数据副本的迁移核对。

View File

@ -0,0 +1,19 @@
import request from './index'
function resource(path) {
return {
list() {
return request.get(path)
},
create(data) {
return request.post(path, data)
},
update(id, data) {
return request.put(`${path}/${id}`, data)
}
}
}
export const materialCategoriesApi = resource('/material-categories')
export const unitsApi = resource('/units')
export const codeRulesApi = resource('/code-rules')

View File

@ -0,0 +1,22 @@
import request from './index'
export const organizationsApi = {
list() {
return request.get('/organizations')
},
create(data) {
return request.post('/organizations', data)
},
update(id, data) {
return request.put(`/organizations/${id}`, data)
},
listRoles() {
return request.get('/roles')
},
listPermissions() {
return request.get('/permissions')
},
updateRole(id, data) {
return request.put(`/roles/${id}`, data)
}
}

View File

@ -0,0 +1,13 @@
import request from './index'
export const partnersApi = {
list(role) {
return request.get('/partners', { params: { role } })
},
create(data) {
return request.post('/partners', data)
},
update(id, data) {
return request.put(`/partners/${id}`, data)
}
}

View File

@ -0,0 +1,9 @@
import request from './index'
export const warehousesApi = {
list() { return request.get('/warehouses') },
create(data) { return request.post('/warehouses', data) },
update(id, data) { return request.put(`/warehouses/${id}`, data) },
createLocation(warehouseId, data) { return request.post(`/warehouses/${warehouseId}/locations`, data) },
updateLocation(warehouseId, id, data) { return request.put(`/warehouses/${warehouseId}/locations/${id}`, data) }
}

View File

@ -0,0 +1,60 @@
const sections = [
{
key: 'master-data',
label: '物料主数据',
adminOnly: true,
items: [
{ path: '/materials', label: '物料', adminOnly: true },
{ path: '/master-data/settings', label: '基础配置', adminOnly: true }
]
},
{
key: 'organizations',
label: '公司组织',
adminOnly: true,
items: [
{ path: '/organizations', label: '组织管理', adminOnly: true },
{ path: '/users', label: '用户管理', adminOnly: true },
{ path: '/roles', label: '角色与权限', adminOnly: true }
]
},
{
key: 'partners',
label: '业务伙伴',
adminOnly: true,
items: [
{ path: '/partners/suppliers', label: '供应商', adminOnly: true },
{ path: '/partners/customers', label: '客户', adminOnly: true }
]
},
{
key: 'inventory',
label: '库存管理',
items: [
{ path: '/stock', label: '库存查询' },
{ path: '/inbound', label: '入库管理', adminOnly: true },
{ path: '/outbound', label: '出库管理', adminOnly: true }
]
}
]
const standalone = [
{ path: '/application', label: '物料申请' },
{ path: '/approval', label: '申请审批', adminOnly: true },
{ path: '/logs', label: '日志记录', adminOnly: true }
]
export function getNavigationMenu({ isAdmin }) {
const canSee = item => !item.adminOnly || isAdmin
return {
sections: sections
.filter(canSee)
.map(section => ({
key: section.key,
label: section.label,
items: section.items.filter(canSee).map(({ path, label }) => ({ path, label }))
}))
.filter(section => section.items.length > 0),
standalone: standalone.filter(canSee).map(item => ({ ...item }))
}
}

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { getNavigationMenu } from './menu'
describe('getNavigationMenu', () => {
it('groups the approved phase-one modules and preserves legacy standalone items', () => {
const menu = getNavigationMenu({ isAdmin: true })
expect(menu.sections.map(section => [
section.label,
section.items.map(item => item.label)
])).toEqual([
['物料主数据', ['物料', '基础配置']],
['公司组织', ['组织管理', '用户管理', '角色与权限']],
['业务伙伴', ['供应商', '客户']],
['库存管理', ['库存查询', '入库管理', '出库管理']]
])
expect(menu.sections.flatMap(section => section.items).map(item => item.path)).toEqual([
'/materials', '/master-data/settings',
'/organizations', '/users', '/roles',
'/partners/suppliers', '/partners/customers',
'/stock', '/inbound', '/outbound'
])
expect(menu.standalone).toEqual([
{ path: '/application', label: '物料申请' },
{ path: '/approval', label: '申请审批', adminOnly: true },
{ path: '/logs', label: '日志记录', adminOnly: true }
])
})
it('keeps the existing non-admin stock and application entries', () => {
const menu = getNavigationMenu({ isAdmin: false })
expect(menu.sections).toEqual([
{
key: 'inventory',
label: '库存管理',
items: [{ path: '/stock', label: '库存查询' }]
}
])
expect(menu.standalone).toEqual([{ path: '/application', label: '物料申请' }])
})
})

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { routes } from './index'
describe('phase-one routes', () => {
it('provides a real page for every new navigation entry', () => {
const paths = routes.map(route => route.path)
expect(paths).toEqual(expect.arrayContaining([
'/master-data/settings',
'/organizations',
'/roles',
'/partners/suppliers',
'/partners/customers'
]))
})
it('keeps the existing application routes unchanged', () => {
const byPath = Object.fromEntries(routes.map(route => [route.path, route]))
expect(byPath['/application'].name).toBe('Application')
expect(byPath['/approval'].name).toBe('Approval')
expect(byPath['/logs'].name).toBe('Logs')
})
})

View File

@ -0,0 +1,32 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test } from 'vitest'
import { useAuthStore } from './auth'
describe('多角色管理员判断', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
test('拥有超级管理员角色的用户具有管理员权限', () => {
const store = useAuthStore()
store.saveAuth('token', {
id: 1,
username: 'admin',
roles: [{ id: 1, code: 'super_admin', name: '超级管理员', enabled: true }]
})
expect(store.isAdmin).toBe(true)
})
test('普通业务角色不具有管理员权限', () => {
const store = useAuthStore()
store.saveAuth('token', {
id: 2,
username: 'buyer',
roles: [{ id: 3, code: 'purchaser', name: '采购人员', enabled: true }]
})
expect(store.isAdmin).toBe(false)
})
})

View File

@ -0,0 +1,44 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inboundApi } from '../api/inbound'
import { useInboundStore } from './inbound'
vi.mock('../api/inbound', () => ({
inboundApi: {
getById: vi.fn()
}
}))
describe('inbound store details', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('returns the inbound items loaded by id', async () => {
inboundApi.getById.mockResolvedValue({
data: {
id: 7,
handler: '张三',
supplier: '供应商 A',
items: [
{
id: 11,
inbound_order_id: 7,
material_id: 3,
material_code: 'MAT-003',
material_name: '电机',
quantity: 5
}
]
}
})
const store = useInboundStore()
const detail = await store.fetchInboundDetail(7)
expect(detail.items).toEqual([
expect.objectContaining({ material_code: 'MAT-003', material_name: '电机', quantity: 5 })
])
})
})

View File

@ -0,0 +1,36 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { stockApi } from '../api/stock'
import { useStockStore } from './stock'
vi.mock('../api/stock', () => ({
stockApi: {
getAll: vi.fn(),
getWarning: vi.fn(),
getById: vi.fn(),
getAllLogs: vi.fn(),
getLogs: vi.fn(),
updateWarning: vi.fn()
}
}))
describe('stock store warning settings', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
stockApi.updateWarning.mockResolvedValue({ data: null })
stockApi.getAll.mockResolvedValue({
data: { items: [{ material_id: 1, warning_stock: 8 }], total: 1 }
})
stockApi.getWarning.mockResolvedValue({ data: { items: [], total: 0 } })
})
it('refreshes inventory state after changing a warning threshold', async () => {
const store = useStockStore()
await store.updateWarningStock(1, 8)
expect(store.stockList).toEqual([{ material_id: 1, warning_stock: 8 }])
expect(store.warningList).toEqual([])
})
})

View File

@ -0,0 +1,36 @@
function isActive(category) {
return category.status === 'active'
}
export function getMajorCategories(categories = []) {
return categories.filter(category => !category.parent_id && isActive(category))
}
export function getMinorCategories(categories = [], majorCategoryId) {
return categories.filter(category => category.parent_id === majorCategoryId && isActive(category))
}
export function resolveCategoryPath(categories = [], categoryId) {
const selected = categories.find(category => category.id === categoryId)
if (!selected) return { major: null, minor: null }
if (!selected.parent_id) return { major: selected, minor: null }
return {
major: categories.find(category => category.id === selected.parent_id) || null,
minor: selected
}
}
// Resolve category by code (e.g., "A0101" returns major=A01, minor=A0101)
export function resolveCategoryByCode(categories = [], categoryCode) {
if (!categoryCode) return { major: null, minor: null }
// Try exact match first (minor)
const minor = categories.find(c => c.code === categoryCode)
if (minor) {
const major = categories.find(c => c.id === minor.parent_id)
return { major, minor }
}
// Try as major code
const major = categories.find(c => c.code === categoryCode && !c.parent_id)
if (major) return { major, minor: null }
return { major: null, minor: null }
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
getMajorCategories,
getMinorCategories,
resolveCategoryPath
} from './materialCategories'
const categories = [
{ id: 1, code: 'A01', name: '标准件', parent_id: null, status: 'active' },
{ id: 2, code: 'A01-G', name: '标准件·通用型', parent_id: 1, status: 'active' },
{ id: 3, code: 'A01-S', name: '标准件·专用型', parent_id: 1, status: 'inactive' },
{ id: 4, code: 'B01', name: '电子成品件', parent_id: null, status: 'active' },
{ id: 5, code: 'B01-G', name: '电子成品件·通用型', parent_id: 4, status: 'active' }
]
describe('material category hierarchy', () => {
it('returns only active root categories as major categories', () => {
expect(getMajorCategories(categories).map(item => item.code)).toEqual(['A01', 'B01'])
})
it('returns only active children of the selected major category', () => {
expect(getMinorCategories(categories, 1).map(item => item.code)).toEqual(['A01-G'])
})
it('resolves a material minor category to its major and minor path', () => {
expect(resolveCategoryPath(categories, 2)).toEqual({
major: categories[0],
minor: categories[1]
})
})
it('keeps legacy root assignments visible while migration is pending', () => {
expect(resolveCategoryPath(categories, 1)).toEqual({
major: categories[0],
minor: null
})
})
})

View File

@ -0,0 +1,36 @@
function normalizeQuantity(value) {
const text = String(value ?? 0).trim()
const match = /^([+-])?(\d*)(?:\.(\d*))?$/.exec(text)
if (!match) return { sign: 0, integer: '0', fraction: '' }
const integer = (match[2] || '0').replace(/^0+(?=\d)/, '')
const fraction = (match[3] || '').replace(/0+$/, '')
const isZero = /^0*$/.test(integer) && fraction === ''
const sign = isZero ? 0 : match[1] === '-' ? -1 : 1
return { sign, integer, fraction }
}
function compareMagnitude(left, right) {
if (left.integer.length !== right.integer.length) {
return left.integer.length > right.integer.length ? 1 : -1
}
if (left.integer !== right.integer) return left.integer > right.integer ? 1 : -1
const length = Math.max(left.fraction.length, right.fraction.length)
const leftFraction = left.fraction.padEnd(length, '0')
const rightFraction = right.fraction.padEnd(length, '0')
if (leftFraction === rightFraction) return 0
return leftFraction > rightFraction ? 1 : -1
}
export function compareQuantities(leftValue, rightValue) {
const left = normalizeQuantity(leftValue)
const right = normalizeQuantity(rightValue)
if (left.sign !== right.sign) return left.sign > right.sign ? 1 : -1
if (left.sign === 0) return 0
const magnitude = compareMagnitude(left, right)
return left.sign > 0 ? magnitude : -magnitude
}
export function isLowStock(stock) {
return compareQuantities(stock?.current_stock, stock?.warning_stock) <= 0
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { compareQuantities, isLowStock } from './quantity'
describe('decimal quantity comparison', () => {
it('compares numeric strings by value instead of lexicographically', () => {
expect(compareQuantities('370.000000', '50.000000')).toBeGreaterThan(0)
expect(isLowStock({ current_stock: '370.000000', warning_stock: '50.000000' })).toBe(false)
})
it('supports equal values and decimal fractions', () => {
expect(isLowStock({ current_stock: '50', warning_stock: '50.000000' })).toBe(true)
expect(compareQuantities('0.25', '0.3')).toBeLessThan(0)
})
})

View File

@ -0,0 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Users from './Users.vue'
import { organizationsApi } from '../api/organizations'
const RoleSelectStub = {
props: {
modelValue: Array,
multiple: Boolean
},
emits: ['update:modelValue'],
template: '<div data-test="role-select" :data-multiple="multiple"><slot /></div>'
}
const RoleOptionStub = {
props: ['label', 'value', 'disabled'],
template: '<span class="role-option">{{ label }}</span>'
}
const usersStore = vi.hoisted(() => ({
users: [],
loading: false,
page: { currentPage: 1, pageSize: 20, total: 0, totalPages: 1 },
fetchUsers: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn()
}))
vi.mock('../stores/users', () => ({ useUsersStore: () => usersStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
vi.mock('../api/organizations', () => ({
organizationsApi: {
listRoles: vi.fn()
}
}))
describe('用户管理角色', () => {
beforeEach(() => {
usersStore.users = [{
id: 7,
username: 'zhangsan',
description: '测试用户',
created_at: '2026-09-16T09:00:00+08:00',
roles: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] }
]
}]
usersStore.fetchUsers.mockReset()
organizationsApi.listRoles.mockResolvedValue({
data: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] }
]
})
})
test('列表显示用户的所有中文角色名称', async () => {
const wrapper = mount(Users, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
}
})
await flushPromises()
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).toContain('采购人员')
expect(wrapper.text()).not.toContain('普通用户')
wrapper.unmount()
})
test('新增和编辑使用角色与权限中的多选角色', async () => {
const wrapper = mount(Users, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
}
})
await flushPromises()
const addButton = wrapper.findAll('button').find(button => button.text().includes('新增用户'))
await addButton.trigger('click')
await flushPromises()
const roleSelect = wrapper.findComponent(RoleSelectStub)
expect(roleSelect.props('multiple')).toBe(true)
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).toContain('采购人员')
wrapper.unmount()
})
})

View File

@ -0,0 +1,47 @@
<template>
<div>
<div class="toolbar"><span>仓库与库位属于库存域,不在物料主数据中维护。</span><el-button type="primary" @click="openWarehouse">新增仓库</el-button></div>
<el-collapse v-loading="loading">
<el-collapse-item v-for="warehouse in warehouses" :key="warehouse.id" :name="warehouse.id">
<template #title><strong>{{ warehouse.code }} - {{ warehouse.name }}</strong><el-tag class="status" :type="warehouse.status === 'active' ? 'success' : 'info'">{{ warehouse.status === 'active' ? '启用' : '停用' }}</el-tag></template>
<div class="location-header"><span>库位</span><el-button link type="primary" @click.stop="openLocation(warehouse)">新增库位</el-button></div>
<el-table :data="warehouse.locations || []" size="small">
<el-table-column prop="code" label="库位编码" /><el-table-column prop="name" label="库位名称" /><el-table-column prop="status" label="状态" />
</el-table>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!loading && warehouses.length === 0" description="暂无仓库" />
<el-dialog v-model="formVisible" :title="formType === 'warehouse' ? '新增仓库' : `新增库位 - ${selectedWarehouse?.name || ''}`" width="420px" append-to-body>
<el-form :model="form" label-width="90px">
<el-form-item label="编码"><el-input v-model="form.code" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
</el-form>
<template #footer><el-button @click="formVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { warehousesApi } from '../../api/warehouses'
const warehouses = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const formType = ref('warehouse')
const selectedWarehouse = ref(null)
const form = reactive({ code: '', name: '', status: 'active' })
async function load() { loading.value = true; try { const response = await warehousesApi.list(); warehouses.value = response.data || [] } finally { loading.value = false } }
function resetForm() { Object.assign(form, { code: '', name: '', status: 'active' }) }
function openWarehouse() { formType.value = 'warehouse'; selectedWarehouse.value = null; resetForm(); formVisible.value = true }
function openLocation(warehouse) { formType.value = 'location'; selectedWarehouse.value = warehouse; resetForm(); formVisible.value = true }
async function save() { saving.value = true; try { if (formType.value === 'warehouse') await warehousesApi.create(form); else await warehousesApi.createLocation(selectedWarehouse.value.id, form); ElMessage.success('保存成功'); formVisible.value = false; await load() } finally { saving.value = false } }
onMounted(load)
</script>
<style scoped>
.toolbar, .location-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; color: #606266; }
.status { margin-left: 10px; }
</style>

View File

@ -0,0 +1,168 @@
<template>
<div class="settings-page">
<div class="page-header">
<div>
<h2>基础配置</h2>
<p>维护物料主数据使用的分类、计量单位和编码规则;此处不管理库存。</p>
</div>
<el-button type="primary" @click="openCreate">新增{{ current.label }}</el-button>
</div>
<el-tabs v-model="activeTab" @tab-change="loadCurrent">
<el-tab-pane v-for="item in tabs" :key="item.key" :label="item.label" :name="item.key" />
</el-tabs>
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column :prop="activeTab === 'rules' ? 'category_code' : 'code'" :label="activeTab === 'rules' ? '分类编码' : '编码'" min-width="130" />
<el-table-column :prop="activeTab === 'rules' ? 'category_name' : 'name'" :label="activeTab === 'rules' ? '适用分类' : '名称'" min-width="160" />
<el-table-column v-if="activeTab === 'categories'" label="上级大类" min-width="160">
<template #default="{ row }">{{ parentCategoryLabel(row.parent_id) }}</template>
</el-table-column>
<el-table-column v-if="activeTab === 'units'" prop="precision_scale" label="小数位" width="100" />
<el-table-column v-if="activeTab === 'rules'" prop="prefix" label="前缀" width="120" />
<el-table-column v-if="activeTab === 'rules'" prop="sequence_length" label="流水号位数" width="120" />
<el-table-column v-if="activeTab === 'rules'" prop="next_sequence" label="下一流水号" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</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-empty v-if="!loading && rows.length === 0" description="暂无配置数据" />
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${current.label}`" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item v-if="activeTab !== 'rules'" label="编码">
<el-input v-model="form.code" :disabled="Boolean(editingId)" />
</el-form-item>
<el-form-item v-if="activeTab !== 'rules'" label="名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item v-if="activeTab === 'categories'" label="上级大类">
<el-select v-model="form.parent_id" clearable placeholder="留空表示大类" style="width: 100%">
<el-option
v-for="category in rootCategories.filter(item => item.id !== editingId)"
:key="category.id"
:label="`${category.code} ${category.name}`"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="activeTab === 'units'" label="小数位">
<el-input-number v-model="form.precision_scale" :min="0" :max="6" />
</el-form-item>
<template v-if="activeTab === 'rules'">
<el-form-item label="物料分类">
<el-select v-model="form.category_id" style="width: 100%">
<el-option v-for="category in categories" :key="category.id" :label="`${category.code} - ${category.name}`" :value="category.id" />
</el-select>
</el-form-item>
<el-form-item label="编码前缀"><el-input v-model="form.prefix" /></el-form-item>
<el-form-item label="流水号位数"><el-input-number v-model="form.sequence_length" :min="1" :max="12" /></el-form-item>
</template>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="停用" value="inactive" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData'
const tabs = [
{ key: 'categories', label: '物料分类', api: materialCategoriesApi },
{ key: 'units', label: '计量单位', api: unitsApi },
{ key: 'rules', label: '编码规则', api: codeRulesApi }
]
const activeTab = ref('categories')
const rows = ref([])
const categories = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
const current = computed(() => tabs.find(item => item.key === activeTab.value))
const rootCategories = computed(() => categories.value.filter(item => !item.parent_id))
const form = reactive(defaultForm())
function defaultForm() {
return { code: '', name: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, precision_scale: 0, status: 'active' }
}
function parentCategoryLabel(parentId) {
if (!parentId) return '—(大类)'
const parent = categories.value.find(item => item.id === parentId)
return parent ? `${parent.code} ${parent.name}` : '-'
}
async function loadCurrent() {
loading.value = true
try {
const response = await current.value.api.list()
rows.value = response.data || []
if (activeTab.value === 'categories') categories.value = rows.value
} finally {
loading.value = false
}
}
async function ensureCategories() {
if (categories.value.length) return
const response = await materialCategoriesApi.list()
categories.value = response.data || []
}
async function openCreate() {
editingId.value = null
Object.assign(form, defaultForm())
if (activeTab.value === 'rules') await ensureCategories()
dialogVisible.value = true
}
async function openEdit(row) {
editingId.value = row.id
Object.assign(form, defaultForm(), row)
if (activeTab.value === 'rules') await ensureCategories()
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (editingId.value) await current.value.api.update(editingId.value, form)
else await current.value.api.create(form)
ElMessage.success('保存成功')
dialogVisible.value = false
await loadCurrent()
} finally {
saving.value = false
}
}
onMounted(loadCurrent)
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; }
</style>

View File

@ -0,0 +1,35 @@
const materialKeys = [
'material_code',
'name',
'spec',
'category',
'category_id',
'unit',
'base_unit_id',
'material_image'
]
export function createMaterialForm(source = {}, categories = []) {
const selectedCategory = categories.find(item => item.id === source.category_id)
const majorCategoryId = selectedCategory?.parent_id || source.major_category_id || null
return {
material_code: source.material_code || '',
name: source.name || '',
spec: source.spec || '',
major_category_id: majorCategoryId,
category: source.category || '',
category_id: source.category_id || null,
unit: source.unit || 'pcs',
base_unit_id: source.base_unit_id || null,
material_image: source.material_image || ''
}
}
export function toMaterialPayload(source) {
const payload = {}
for (const key of materialKeys) payload[key] = source[key] || ''
payload.category_id = source.category_id || null
payload.base_unit_id = source.base_unit_id || null
if (!payload.unit) payload.unit = 'pcs'
return payload
}

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { createMaterialForm, toMaterialPayload } from './materialForm'
describe('material master-data form', () => {
it('contains only material master-data fields', () => {
expect(createMaterialForm()).toEqual({
material_code: '',
name: '',
spec: '',
major_category_id: null,
category: '',
category_id: null,
unit: 'pcs',
base_unit_id: null,
material_image: ''
})
})
it('does not submit inventory fields received from a list row', () => {
expect(toMaterialPayload({
material_code: 'MAT-001',
name: '轴承',
spec: '6205',
major_category_id: 10,
category: 'A02',
category_id: 12,
unit: '件',
base_unit_id: 3,
material_image: '',
current_stock: 99,
warning_stock: 10
})).toEqual({
material_code: 'MAT-001',
name: '轴承',
spec: '6205',
category: 'A02',
category_id: 12,
unit: '件',
base_unit_id: 3,
material_image: ''
})
})
it('restores the major category when editing a material assigned to a minor category', () => {
const categories = [
{ id: 10, code: 'A02', name: '成品件', parent_id: null },
{ id: 12, code: 'A02-G', name: '成品件·通用型', parent_id: 10 }
]
expect(createMaterialForm({ category_id: 12, category: 'A02-G' }, categories)).toMatchObject({
major_category_id: 10,
category_id: 12,
category: 'A02-G'
})
})
})

View File

@ -0,0 +1,53 @@
<template>
<div>
<div class="page-header">
<div><h2>组织管理</h2><p>维护本系统所属的公司和经营组织,与供应商、客户主表分开。</p></div>
<el-button type="primary" @click="openCreate">新增组织</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column prop="code" label="组织编码" />
<el-table-column prop="name" label="组织名称" />
<el-table-column prop="default_timezone" label="时区" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></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-empty v-if="!loading && rows.length === 0" description="暂无公司组织" />
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑组织' : '新增组织'" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item label="组织编码"><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item>
<el-form-item label="组织名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="时区"><el-input v-model="form.default_timezone" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status" style="width: 100%"><el-option label="启用" value="active" /><el-option label="停用" value="inactive" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { organizationsApi } from '../../api/organizations'
const rows = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
const form = reactive({ code: '', name: '', default_timezone: 'Asia/Shanghai', status: 'active' })
async function load() { loading.value = true; try { const response = await organizationsApi.list(); rows.value = response.data || [] } finally { loading.value = false } }
function openCreate() { editingId.value = null; Object.assign(form, { code: '', name: '', default_timezone: 'Asia/Shanghai', status: 'active' }); dialogVisible.value = true }
function openEdit(row) { editingId.value = row.id; Object.assign(form, row); dialogVisible.value = true }
async function save() { saving.value = true; try { if (editingId.value) await organizationsApi.update(editingId.value, form); else await organizationsApi.create(form); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } }
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; }
</style>

View File

@ -0,0 +1,44 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Roles from './Roles.vue'
import { organizationsApi } from '../../api/organizations'
vi.mock('../../api/organizations', () => ({
organizationsApi: {
listRoles: vi.fn(),
listPermissions: vi.fn(),
updateRole: vi.fn()
}
}))
describe('角色与权限页面', () => {
beforeEach(() => {
organizationsApi.listRoles.mockResolvedValue({
data: [{
id: 2,
code: 'warehouse_admin',
name: '仓库管理员',
enabled: true,
permissions: []
}]
})
organizationsApi.listPermissions.mockResolvedValue({ data: [] })
})
test('角色列表只显示角色名称,不显示角色编码', async () => {
const wrapper = mount(Roles, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true }
}
})
await flushPromises()
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).not.toContain('角色编码')
expect(wrapper.text()).not.toContain('warehouse_admin')
wrapper.unmount()
})
})

View File

@ -0,0 +1,127 @@
<template>
<div>
<div class="page-header">
<h2>角色与权限</h2>
<p>角色用于组合功能权限;用户、组织和仓库的数据范围单独授权。</p>
</div>
<el-table v-loading="loading" :data="roles" stripe>
<el-table-column prop="name" label="角色名称" width="140" />
<el-table-column label="功能权限" min-width="500">
<template #default="{ row }">
<div v-if="row.permissions?.length" class="permission-list">
<el-tag v-for="permission in row.permissions" :key="permission.code" class="permission-tag" effect="plain">
{{ permissionDisplayName(permission) }}
<span class="permission-code">{{ permission.code }}</span>
</el-tag>
</div>
<span v-else class="empty-permissions">暂未配置权限</span>
</template>
</el-table-column>
<el-table-column prop="enabled" label="状态" width="90">
<template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-tag v-if="row.code === 'super_admin'" type="info">系统内置</el-tag>
<el-button v-else link type="primary" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && roles.length === 0" description="暂无角色" />
<el-dialog v-model="dialogVisible" :title="`编辑角色 - ${editingRole?.name || ''}`" width="720px">
<el-form :model="form" label-width="90px">
<el-form-item label="角色名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
<el-form-item label="功能权限">
<el-checkbox-group v-model="form.permission_codes" class="permission-groups">
<section v-for="group in permissionGroups" :key="group.key" class="permission-group">
<h4>{{ group.label }}</h4>
<div class="permission-options">
<el-checkbox v-for="permission in group.permissions" :key="permission.code" :label="permission.code">
<span>{{ permissionDisplayName(permission) }}</span>
<small>{{ permission.code }}</small>
</el-checkbox>
</div>
</section>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveRole">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { organizationsApi } from '../../api/organizations'
import { groupPermissions, permissionDisplayName } from './rolePermissions'
const roles = ref([])
const permissions = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingRole = ref(null)
const form = reactive({ name: '', enabled: true, permission_codes: [] })
const permissionGroups = computed(() => groupPermissions(permissions.value))
async function load() {
loading.value = true
try {
const [roleResponse, permissionResponse] = await Promise.all([
organizationsApi.listRoles(),
organizationsApi.listPermissions()
])
roles.value = roleResponse.data || []
permissions.value = permissionResponse.data || []
} finally {
loading.value = false
}
}
function openEdit(role) {
editingRole.value = role
Object.assign(form, {
name: role.name,
enabled: role.enabled,
permission_codes: (role.permissions || []).map(permission => permission.code)
})
dialogVisible.value = true
}
async function saveRole() {
if (!editingRole.value) return
saving.value = true
try {
await organizationsApi.updateRole(editingRole.value.id, form)
ElMessage.success('角色权限已保存')
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page-header { margin-bottom: 20px; }
.page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; }
.permission-list { display: flex; flex-wrap: wrap; gap: 6px; }
.permission-tag { height: auto; padding: 4px 8px; }
.permission-code { margin-left: 6px; color: #909399; font-size: 11px; }
.empty-permissions { color: #909399; }
.permission-groups { width: 100%; }
.permission-group { margin-bottom: 18px; }
.permission-group h4 { margin: 0 0 8px; color: #303133; }
.permission-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; }
.permission-options :deep(.el-checkbox) { height: auto; margin-right: 0; align-items: flex-start; }
.permission-options small { display: block; color: #909399; line-height: 18px; }
</style>

View File

@ -0,0 +1,28 @@
const moduleLabels = {
master: '物料主数据',
organization: '公司组织',
partner: '业务伙伴',
inventory: '库存管理'
}
const moduleOrder = ['master', 'organization', 'partner', 'inventory']
export function permissionDisplayName(permission) {
return permission?.name || permission?.code || '-'
}
export function groupPermissions(permissions = []) {
const grouped = new Map()
for (const permission of permissions) {
const module = permission.code?.split('.')[0] || 'other'
if (!grouped.has(module)) grouped.set(module, [])
grouped.get(module).push(permission)
}
const keys = [...grouped.keys()].sort((left, right) => {
const leftIndex = moduleOrder.indexOf(left)
const rightIndex = moduleOrder.indexOf(right)
return (leftIndex < 0 ? moduleOrder.length : leftIndex) - (rightIndex < 0 ? moduleOrder.length : rightIndex)
})
return keys.map(key => ({ key, label: moduleLabels[key] || '其他权限', permissions: grouped.get(key) }))
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { groupPermissions, permissionDisplayName } from './rolePermissions'
describe('role permission presentation', () => {
it('uses the Chinese permission name as the primary label', () => {
expect(permissionDisplayName({ code: 'inventory.stock.read', name: '查看库存' })).toBe('查看库存')
})
it('groups permissions using Chinese module names', () => {
const groups = groupPermissions([
{ code: 'master.material.read', name: '查看物料' },
{ code: 'inventory.stock.read', name: '查看库存' }
])
expect(groups.map(group => group.label)).toEqual(['物料主数据', '库存管理'])
})
})

View File

@ -0,0 +1,4 @@
<template><PartnerList role="customer" title="客户" /></template>
<script setup>
import PartnerList from './PartnerList.vue'
</script>

View File

@ -0,0 +1,76 @@
<template>
<div>
<div class="page-header">
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column prop="partner_code" label="伙伴编码" width="140" />
<el-table-column prop="legal_name" 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_phone" label="联系电话" width="140" />
<el-table-column :prop="statusField" label="合作状态" width="110" />
<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-empty v-if="!loading && rows.length === 0" :description="`暂无${title}`" />
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
<el-form :model="form" label-width="130px">
<el-form-item label="伙伴编码"><el-input v-model="form.partner_code" :disabled="Boolean(editingId)" /></el-form-item>
<el-form-item label="企业名称"><el-input v-model="form.legal_name" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="form.unified_credit_code" /></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="合作状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="暂停" value="suspended" />
<el-option label="淘汰" value="eliminated" />
<el-option label="待审核" value="pending" />
<el-option label="停用" value="inactive" />
</el-select>
</el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { partnersApi } from '../../api/partners'
const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } })
const rows = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
const statusField = computed(() => `${props.role}_status`)
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 } }
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 }
function openEdit(row) { editingId.value = row.id; Object.assign(form, row, { status: row[statusField.value] || 'active' }); dialogVisible.value = true }
function payload() {
return {
...form,
is_supplier: form.is_supplier || props.role === 'supplier',
is_customer: form.is_customer || props.role === 'customer',
[`${props.role}_status`]: form.status
}
}
async function save() { saving.value = true; try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } }
watch(() => props.role, load)
onMounted(load)
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; }
</style>

View File

@ -0,0 +1,4 @@
<template><PartnerList role="supplier" title="供应商" /></template>
<script setup>
import PartnerList from './PartnerList.vue'
</script>

View File

@ -0,0 +1,402 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response"
)
func organizationID(c *gin.Context) (int64, bool) {
value := c.GetHeader("X-Organization-ID")
if value == "" {
return 1, true
}
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "invalid X-Organization-ID")
return 0, false
}
return id, true
}
func pathID(c *gin.Context) (int64, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "invalid id")
return 0, false
}
return id, true
}
func bind(c *gin.Context, value interface{}) bool {
if err := c.ShouldBindJSON(value); err != nil {
response.BadRequest(c, err.Error())
return false
}
return true
}
func (h *Handler) ListOrganizations(c *gin.Context) {
items, err := h.organizationSvc.List()
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) ListRoles(c *gin.Context) {
items, err := h.organizationSvc.ListRoles()
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) ListPermissions(c *gin.Context) {
items, err := h.organizationSvc.ListPermissions()
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) UpdateRole(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req model.UpdateRoleRequest
if !bind(c, &req) {
return
}
if err := h.organizationSvc.UpdateRole(id, &req); err != nil {
if errors.Is(err, service.ErrProtectedRole) {
response.Error(c, 409, err.Error())
return
}
response.BadRequest(c, err.Error())
return
}
response.SuccessWithMessage(c, "Role updated successfully", nil)
}
func (h *Handler) CreateOrganization(c *gin.Context) {
var item model.Organization
if !bind(c, &item) {
return
}
if err := h.organizationSvc.Create(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Organization created successfully", item)
}
func (h *Handler) UpdateOrganization(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var item model.Organization
if !bind(c, &item) {
return
}
item.ID = id
if err := h.organizationSvc.Update(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) ListMaterialCategories(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
items, err := h.masterDataSvc.ListCategories(orgID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) CreateMaterialCategory(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
var item model.MaterialCategory
if !bind(c, &item) {
return
}
item.OrganizationID = orgID
if err := h.masterDataSvc.SaveCategory(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Material category created successfully", item)
}
func (h *Handler) UpdateMaterialCategory(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
id, ok := pathID(c)
if !ok {
return
}
var item model.MaterialCategory
if !bind(c, &item) {
return
}
item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveCategory(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) ListUnits(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
items, err := h.masterDataSvc.ListUnits(orgID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) CreateUnit(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
var item model.Unit
if !bind(c, &item) {
return
}
item.OrganizationID = orgID
if err := h.masterDataSvc.SaveUnit(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Unit created successfully", item)
}
func (h *Handler) UpdateUnit(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
id, ok := pathID(c)
if !ok {
return
}
var item model.Unit
if !bind(c, &item) {
return
}
item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveUnit(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) ListCodeRules(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
items, err := h.masterDataSvc.ListCodeRules(orgID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) CreateCodeRule(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
var item model.CodeRule
if !bind(c, &item) {
return
}
item.OrganizationID = orgID
if err := h.masterDataSvc.SaveCodeRule(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Code rule created successfully", item)
}
func (h *Handler) UpdateCodeRule(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
id, ok := pathID(c)
if !ok {
return
}
var item model.CodeRule
if !bind(c, &item) {
return
}
item.ID, item.OrganizationID = id, orgID
if err := h.masterDataSvc.SaveCodeRule(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) ListPartners(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
items, err := h.partnerSvc.List(orgID, c.Query("role"))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) CreatePartner(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
var item model.BusinessPartner
if !bind(c, &item) {
return
}
item.OrganizationID = orgID
if err := h.partnerSvc.Save(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Partner created successfully", item)
}
func (h *Handler) UpdatePartner(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
id, ok := pathID(c)
if !ok {
return
}
var item model.BusinessPartner
if !bind(c, &item) {
return
}
item.ID, item.OrganizationID = id, orgID
if err := h.partnerSvc.Save(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) ListWarehouses(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
items, err := h.warehouseSvc.List(orgID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, items)
}
func (h *Handler) CreateWarehouse(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
var item model.Warehouse
if !bind(c, &item) {
return
}
item.OrganizationID = orgID
if err := h.warehouseSvc.Save(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Warehouse created successfully", item)
}
func (h *Handler) UpdateWarehouse(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
id, ok := pathID(c)
if !ok {
return
}
var item model.Warehouse
if !bind(c, &item) {
return
}
item.ID, item.OrganizationID = id, orgID
if err := h.warehouseSvc.Save(&item); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}
func (h *Handler) CreateWarehouseLocation(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
warehouseID, ok := pathID(c)
if !ok {
return
}
var item model.WarehouseLocation
if !bind(c, &item) {
return
}
item.WarehouseID = warehouseID
if err := h.warehouseSvc.SaveLocation(&item, orgID); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, "Location created successfully", item)
}
func (h *Handler) UpdateWarehouseLocation(c *gin.Context) {
orgID, ok := organizationID(c)
if !ok {
return
}
warehouseID, ok := pathID(c)
if !ok {
return
}
locationID, err := strconv.ParseInt(c.Param("locationId"), 10, 64)
if err != nil || locationID <= 0 {
response.BadRequest(c, "invalid location id")
return
}
var item model.WarehouseLocation
if !bind(c, &item) {
return
}
item.ID, item.WarehouseID = locationID, warehouseID
if err := h.warehouseSvc.SaveLocation(&item, orgID); err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, item)
}

View File

@ -0,0 +1,37 @@
package handler
import (
"testing"
"github.com/gin-gonic/gin"
)
func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler().RegisterRoutes(router)
registered := make(map[string]bool)
for _, route := range router.Routes() {
registered[route.Method+" "+route.Path] = true
}
want := []string{
"GET /api/material-categories", "POST /api/material-categories", "PUT /api/material-categories/:id",
"GET /api/units", "POST /api/units", "PUT /api/units/:id",
"GET /api/code-rules", "POST /api/code-rules", "PUT /api/code-rules/:id",
"GET /api/organizations", "GET /api/roles",
"PUT /api/roles/:id", "GET /api/permissions",
"GET /api/partners", "POST /api/partners", "PUT /api/partners/:id",
"GET /api/warehouses", "POST /api/warehouses/:id/locations",
"POST /api/materials/:id/obsolete", "PUT /api/stock/:materialId/warning",
"POST /api/applications", "GET /api/logs",
}
for _, route := range want {
if !registered[route] {
t.Errorf("route not registered: %s", route)
}
}
if registered["DELETE /api/materials/:id"] {
t.Error("hard-delete material route must not be exposed")
}
}

View File

@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestAdminOnlyAcceptsSuperAdminAmongMultipleRoles(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/", func(c *gin.Context) {
c.Set(RolesKey, []string{"purchaser", "super_admin"})
c.Next()
}, AdminOnly(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
if response.Code != http.StatusNoContent {
t.Fatalf("expected super admin access, got status %d", response.Code)
}
}
func TestAdminOnlyRejectsBusinessRoles(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/", func(c *gin.Context) {
c.Set(RolesKey, []string{"warehouse_admin", "purchaser"})
c.Next()
}, AdminOnly(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
if response.Code != http.StatusForbidden {
t.Fatalf("expected forbidden status, got %d", response.Code)
}
}

View File

@ -0,0 +1,32 @@
package model
import (
"time"
"github.com/shopspring/decimal"
)
// InventoryBalance is the inventory-domain quantity for one storage dimension.
type InventoryBalance struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
WarehouseID int64 `json:"warehouse_id"`
LocationID int64 `json:"location_id"`
MaterialID int64 `json:"material_id"`
OnHand decimal.Decimal `json:"on_hand"`
Reserved decimal.Decimal `json:"reserved"`
Locked decimal.Decimal `json:"locked"`
InTransit decimal.Decimal `json:"in_transit"`
Version int `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
}
// StockWarningRule stores an inventory threshold independently of material master data.
type StockWarningRule struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
WarehouseID int64 `json:"warehouse_id"`
MaterialID int64 `json:"material_id"`
MinimumAvailable decimal.Decimal `json:"minimum_available"`
Enabled bool `json:"enabled"`
}

View File

@ -0,0 +1,31 @@
package model
type MaterialCategory struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
Code string `json:"code"`
Name string `json:"name"`
ParentID *int64 `json:"parent_id,omitempty"`
Status string `json:"status"`
}
type Unit struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
Code string `json:"code"`
Name string `json:"name"`
PrecisionScale int `json:"precision_scale"`
Status string `json:"status"`
}
type CodeRule struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
CategoryID int64 `json:"category_id"`
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Prefix string `json:"prefix"`
SequenceLength int `json:"sequence_length"`
NextSequence int64 `json:"next_sequence"`
Status string `json:"status"`
}

View File

@ -0,0 +1,35 @@
package model
import "time"
type Organization struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
DefaultTimezone string `json:"default_timezone"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Role struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Permissions []Permission `json:"permissions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Permission struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
type UpdateRoleRequest struct {
Name string `json:"name" binding:"required"`
Enabled bool `json:"enabled"`
PermissionCodes []string `json:"permission_codes"`
}

19
internal/model/partner.go Normal file
View File

@ -0,0 +1,19 @@
package model
type BusinessPartner struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
BusinessPartnerID int64 `json:"business_partner_id"`
PartnerCode string `json:"partner_code"`
LegalName string `json:"legal_name"`
UnifiedCreditCode string `json:"unified_credit_code"`
ContactName string `json:"contact_name"`
ContactPhone string `json:"contact_phone"`
Address string `json:"address"`
IsSupplier bool `json:"is_supplier"`
IsCustomer bool `json:"is_customer"`
SupplierStatus string `json:"supplier_status"`
CustomerStatus string `json:"customer_status"`
SettlementCurrency string `json:"settlement_currency"`
PaymentTerms string `json:"payment_terms"`
}

View File

@ -0,0 +1,18 @@
package model
type Warehouse struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Locations []WarehouseLocation `json:"locations"`
}
type WarehouseLocation struct {
ID int64 `json:"id"`
WarehouseID int64 `json:"warehouse_id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
}

View File

@ -0,0 +1,105 @@
package repository
import (
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
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, 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.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, parent_id, status) VALUES (?, ?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.ParentID, item.Status)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error {
_, err := database.DB.Exec(`UPDATE material_categories SET name = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.ParentID, item.Status, item.ID, item.OrganizationID)
return err
}
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 {
_, 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
}
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 err
}
item.ID, err = result.LastInsertId()
return err
}
func (r *MasterDataRepository) UpdateCodeRule(item *model.CodeRule) error {
_, err := database.DB.Exec(`UPDATE code_rules SET prefix = ?, sequence_length = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Prefix, item.SequenceLength, item.Status, item.ID, item.OrganizationID)
return err
}

View File

@ -0,0 +1,177 @@
package repository
import (
"errors"
"fmt"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
type OrganizationRepository struct{}
var ErrProtectedRole = errors.New("super administrator role cannot be changed")
func NewOrganizationRepository() *OrganizationRepository { return &OrganizationRepository{} }
func (r *OrganizationRepository) List() ([]model.Organization, error) {
rows, err := database.DB.Query(`SELECT id, code, name, status, default_timezone, created_at, updated_at
FROM organizations ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
organizations := make([]model.Organization, 0)
for rows.Next() {
var item model.Organization
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Status, &item.DefaultTimezone, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
organizations = append(organizations, item)
}
return organizations, rows.Err()
}
func (r *OrganizationRepository) Create(item *model.Organization) error {
result, err := database.DB.Exec(`INSERT INTO organizations (code, name, status, default_timezone) VALUES (?, ?, ?, ?)`, item.Code, item.Name, item.Status, item.DefaultTimezone)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
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
}
func (r *OrganizationRepository) ListRoles() ([]model.Role, error) {
rows, err := database.DB.Query(`SELECT id, code, name, enabled, created_at, updated_at FROM roles ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]model.Role, 0)
roleIndexes := make(map[int64]int)
for rows.Next() {
var item model.Role
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
item.Permissions = []model.Permission{}
roleIndexes[item.ID] = len(roles)
roles = append(roles, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
permissionRows, err := database.DB.Query(`SELECT rp.role_id, p.id, p.code, p.name
FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id
ORDER BY rp.role_id, p.code`)
if err != nil {
return nil, err
}
defer permissionRows.Close()
for permissionRows.Next() {
var roleID int64
var permission model.Permission
if err := permissionRows.Scan(&roleID, &permission.ID, &permission.Code, &permission.Name); err != nil {
return nil, err
}
if index, ok := roleIndexes[roleID]; ok {
roles[index].Permissions = append(roles[index].Permissions, permission)
}
}
return roles, permissionRows.Err()
}
func (r *OrganizationRepository) ListPermissions() ([]model.Permission, error) {
rows, err := database.DB.Query(`SELECT id, code, name FROM permissions ORDER BY code`)
if err != nil {
return nil, err
}
defer rows.Close()
permissions := make([]model.Permission, 0)
for rows.Next() {
var permission model.Permission
if err := rows.Scan(&permission.ID, &permission.Code, &permission.Name); err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
return permissions, rows.Err()
}
func (r *OrganizationRepository) UpdateRole(roleID int64, name string, enabled bool, permissionCodes []string) (err error) {
tx, err := database.DB.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var roleCode string
if err = tx.QueryRow(`SELECT code FROM roles WHERE id = ? FOR UPDATE`, roleID).Scan(&roleCode); err != nil {
return err
}
if roleCode == "super_admin" {
return ErrProtectedRole
}
if _, err = tx.Exec(`UPDATE roles SET name = ?, enabled = ? WHERE id = ?`, name, enabled, roleID); err != nil {
return err
}
if _, err = tx.Exec(`DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil {
return err
}
permissionCodes = uniqueStrings(permissionCodes)
if len(permissionCodes) > 0 {
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(permissionCodes)), ", ")
query := fmt.Sprintf(`INSERT INTO role_permissions (role_id, permission_id)
SELECT ?, id FROM permissions WHERE code IN (%s)`, placeholders)
args := make([]interface{}, 0, len(permissionCodes)+1)
args = append(args, roleID)
for _, code := range permissionCodes {
args = append(args, code)
}
result, execErr := tx.Exec(query, args...)
if execErr != nil {
err = execErr
return err
}
var affected int64
affected, err = result.RowsAffected()
if err != nil {
return err
}
if affected != int64(len(permissionCodes)) {
return fmt.Errorf("one or more permission codes do not exist")
}
}
return tx.Commit()
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@ -0,0 +1,94 @@
package repository
import (
"errors"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/pkg/database"
)
func TestListRolesIncludesChinesePermissionNames(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
now := time.Date(2026, 9, 16, 9, 0, 0, 0, time.Local)
roleQuery := `SELECT id, code, name, enabled, created_at, updated_at FROM roles ORDER BY id`
mock.ExpectQuery(regexp.QuoteMeta(roleQuery)).WillReturnRows(
sqlmock.NewRows([]string{"id", "code", "name", "enabled", "created_at", "updated_at"}).
AddRow(2, "warehouse_admin", "仓库管理员", true, now, now),
)
permissionQuery := `SELECT rp.role_id, p.id, p.code, p.name
FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id
ORDER BY rp.role_id, p.code`
mock.ExpectQuery(regexp.QuoteMeta(permissionQuery)).WillReturnRows(
sqlmock.NewRows([]string{"role_id", "id", "code", "name"}).
AddRow(2, 12, "inventory.stock.read", "查看库存"),
)
roles, err := NewOrganizationRepository().ListRoles()
if err != nil {
t.Fatalf("list roles: %v", err)
}
if len(roles) != 1 || len(roles[0].Permissions) != 1 {
t.Fatalf("unexpected roles: %+v", roles)
}
if roles[0].Permissions[0].Name != "查看库存" {
t.Fatalf("permission name not returned: %+v", roles[0].Permissions[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUpdateRoleRejectsSuperAdminChanges(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.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT code FROM roles WHERE id = ? FOR UPDATE`)).WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"code"}).AddRow("super_admin"))
mock.ExpectRollback()
err = NewOrganizationRepository().UpdateRole(1, "超级管理员", false, nil)
if !errors.Is(err, ErrProtectedRole) {
t.Fatalf("expected protected role error, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUpdateRoleReplacesPermissionsInTransaction(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.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT code FROM roles WHERE id = ? FOR UPDATE`)).WithArgs(int64(3)).WillReturnRows(sqlmock.NewRows([]string{"code"}).AddRow("purchaser"))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE roles SET name = ?, enabled = ? WHERE id = ?`)).WithArgs("采购人员", true, int64(3)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM role_permissions WHERE role_id = ?`)).WithArgs(int64(3)).WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO role_permissions (role_id, permission_id)
SELECT ?, id FROM permissions WHERE code IN (?, ?)`)).WithArgs(int64(3), "inventory.stock.read", "partner.supplier.read").WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectCommit()
err = NewOrganizationRepository().UpdateRole(3, "采购人员", true, []string{"inventory.stock.read", "partner.supplier.read"})
if err != nil {
t.Fatalf("update role: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,122 @@
package repository
import (
"fmt"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
type PartnerRepository struct{}
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
func (r *PartnerRepository) List(organizationID int64, role string) ([]model.BusinessPartner, error) {
query := `SELECT op.id, op.organization_id, bp.id, op.partner_code, bp.legal_name,
COALESCE(bp.unified_credit_code, ''), COALESCE(bp.contact_name, ''),
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
COALESCE(op.payment_terms, '')
FROM organization_partners op
JOIN business_partners bp ON bp.id = op.business_partner_id
WHERE op.organization_id = ?`
args := []interface{}{organizationID}
if role == "supplier" {
query += ` AND op.is_supplier = TRUE`
}
if role == "customer" {
query += ` AND op.is_customer = TRUE`
}
query += ` ORDER BY op.partner_code`
rows, err := database.DB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]model.BusinessPartner, 0)
for rows.Next() {
var item model.BusinessPartner
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {
tx, err := database.DB.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
result, err := tx.Exec(`INSERT INTO business_partners
(legal_name, unified_credit_code, contact_name, contact_phone, address)
VALUES (?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`,
item.LegalName, item.UnifiedCreditCode, item.ContactName, item.ContactPhone, item.Address)
if err != nil {
return err
}
item.BusinessPartnerID, err = result.LastInsertId()
if err != nil {
return err
}
result, err = tx.Exec(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
supplier_status, customer_status, settlement_currency, payment_terms)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`,
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer,
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
if err != nil {
return err
}
return tx.Commit()
}
func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
tx, err := database.DB.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
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 {
return err
}
if _, err = tx.Exec(`UPDATE business_partners SET legal_name = ?, unified_credit_code = NULLIF(?, ''),
contact_name = NULLIF(?, ''), contact_phone = NULLIF(?, ''), address = NULLIF(?, '') WHERE id = ?`,
item.LegalName, item.UnifiedCreditCode, item.ContactName, item.ContactPhone, item.Address, businessPartnerID); err != nil {
return err
}
if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''),
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, '') WHERE id = ? AND organization_id = ?`,
item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus,
item.SettlementCurrency, item.PaymentTerms, item.ID, item.OrganizationID); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit partner update: %w", err)
}
return nil
}

View File

@ -0,0 +1,51 @@
package repository
import (
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(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
partner := &model.BusinessPartner{
OrganizationID: 1,
PartnerCode: "SUP-001",
LegalName: "示例供应商",
IsSupplier: true,
SupplierStatus: "active",
}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO business_partners
(legal_name, unified_credit_code, contact_name, contact_phone, address)
VALUES (?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`)).
WithArgs("示例供应商", "", "", "", "").
WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
supplier_status, customer_status, settlement_currency, payment_terms)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`)).
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "").
WillReturnResult(sqlmock.NewResult(15, 1))
mock.ExpectCommit()
if err := NewPartnerRepository().Create(partner); err != nil {
t.Fatalf("create partner: %v", err)
}
if partner.ID != 15 || partner.BusinessPartnerID != 9 {
t.Fatalf("unexpected identifiers: %+v", partner)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,82 @@
package repository
import (
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
func TestListUsersIncludesAllAssignedRoles(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
now := time.Date(2026, 9, 16, 9, 0, 0, 0, time.Local)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM users`)).WillReturnRows(
sqlmock.NewRows([]string{"count"}).AddRow(1),
)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, username, password, description, created_at FROM users ORDER BY id LIMIT ? OFFSET ?`)).
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "password", "description", "created_at"}).
AddRow(7, "zhangsan", "hash", "测试用户", now))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT ur.user_id, r.id, r.code, r.name, r.enabled, r.created_at, r.updated_at
FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id IN (?) ORDER BY ur.user_id, r.id`)).
WithArgs(int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "id", "code", "name", "enabled", "created_at", "updated_at"}).
AddRow(7, 2, "warehouse_admin", "仓库管理员", true, now, now).
AddRow(7, 3, "purchaser", "采购人员", true, now, now))
users, total, err := NewUserRepository().List("", 20, 0)
if err != nil {
t.Fatalf("list users: %v", err)
}
if total != 1 || len(users) != 1 || len(users[0].Roles) != 2 {
t.Fatalf("unexpected users: total=%d users=%+v", total, users)
}
if users[0].Roles[1].Name != "采购人员" {
t.Fatalf("role names not loaded: %+v", users[0].Roles)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateUserAssignsMultipleEnabledRolesInTransaction(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.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO users (username, password, description) VALUES (?, ?, ?)`)).
WithArgs("zhangsan", "hash", "测试用户").
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?, ?)`)).
WithArgs(int64(2), int64(3)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?), (?, ?)`)).
WithArgs(int64(7), int64(2), int64(7), int64(3)).
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectCommit()
user := &model.User{Username: "zhangsan", Password: "hash", Description: "测试用户"}
if err := NewUserRepository().Create(user, []int64{2, 3}); err != nil {
t.Fatalf("create user: %v", err)
}
if user.ID != 7 {
t.Fatalf("expected user id 7, got %d", user.ID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,75 @@
package repository
import (
"database/sql"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
type WarehouseRepository struct{}
func NewWarehouseRepository() *WarehouseRepository { return &WarehouseRepository{} }
func (r *WarehouseRepository) List(organizationID int64) ([]model.Warehouse, error) {
rows, err := database.DB.Query(`SELECT w.id, w.organization_id, w.code, w.name, w.status,
l.id, l.code, l.name, l.status
FROM warehouses w
LEFT JOIN warehouse_locations l ON l.warehouse_id = w.id
WHERE w.organization_id = ? ORDER BY w.code, l.code`, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]model.Warehouse, 0)
index := make(map[int64]int)
for rows.Next() {
var warehouse model.Warehouse
var locationID sql.NullInt64
var locationCode, locationName, locationStatus sql.NullString
if err := rows.Scan(&warehouse.ID, &warehouse.OrganizationID, &warehouse.Code, &warehouse.Name, &warehouse.Status, &locationID, &locationCode, &locationName, &locationStatus); err != nil {
return nil, err
}
position, exists := index[warehouse.ID]
if !exists {
warehouse.Locations = []model.WarehouseLocation{}
items = append(items, warehouse)
position = len(items) - 1
index[warehouse.ID] = position
}
if locationID.Valid {
items[position].Locations = append(items[position].Locations, model.WarehouseLocation{ID: locationID.Int64, WarehouseID: warehouse.ID, Code: locationCode.String, Name: locationName.String, Status: locationStatus.String})
}
}
return items, rows.Err()
}
func (r *WarehouseRepository) Create(item *model.Warehouse) error {
result, err := database.DB.Exec(`INSERT INTO warehouses (organization_id, code, name, status) VALUES (?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.Status)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
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
}
func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error {
result, err := database.DB.Exec(`INSERT INTO warehouse_locations (warehouse_id, code, name, status)
SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`, item.Code, item.Name, item.Status, item.WarehouseID, organizationID)
if err != nil {
return err
}
item.ID, err = result.LastInsertId()
return err
}
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
SET l.name = ?, l.status = ? WHERE l.id = ? AND w.organization_id = ?`, item.Name, item.Status, item.ID, organizationID)
return err
}

View File

@ -0,0 +1,196 @@
package service
import (
"errors"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
var errInvalidMasterData = errors.New("code and name are required")
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)
}
func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) 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"
}
if item.ID == 0 {
return s.repo.CreateCategory(item)
}
return s.repo.UpdateCategory(item)
}
func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) {
return s.repo.ListUnits(orgID)
}
func (s *MasterDataService) SaveUnit(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"
}
if item.PrecisionScale < 0 || item.PrecisionScale > 6 {
return errors.New("precision_scale must be between 0 and 6")
}
if item.ID == 0 {
return s.repo.CreateUnit(item)
}
return s.repo.UpdateUnit(item)
}
func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) {
return s.repo.ListCodeRules(orgID)
}
func (s *MasterDataService) SaveCodeRule(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"
}
if item.ID == 0 {
return s.repo.CreateCodeRule(item)
}
return s.repo.UpdateCodeRule(item)
}
type PartnerService struct{ repo *repository.PartnerRepository }
func NewPartnerService() *PartnerService {
return &PartnerService{repo: repository.NewPartnerRepository()}
}
func (s *PartnerService) List(orgID int64, role string) ([]model.BusinessPartner, error) {
if role != "" && role != "supplier" && role != "customer" {
return nil, errors.New("role must be supplier or customer")
}
return s.repo.List(orgID, role)
}
func (s *PartnerService) Save(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")
}
if item.IsSupplier && item.SupplierStatus == "" {
item.SupplierStatus = "active"
}
if item.IsCustomer && item.CustomerStatus == "" {
item.CustomerStatus = "active"
}
if item.ID == 0 {
return s.repo.Create(item)
}
return s.repo.Update(item)
}
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)
}
func (s *WarehouseService) Save(item *model.Warehouse) 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"
}
if item.ID == 0 {
return s.repo.Create(item)
}
return s.repo.Update(item)
}
func (s *WarehouseService) SaveLocation(item *model.WarehouseLocation, orgID int64) error {
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"
}
if item.ID == 0 {
return s.repo.CreateLocation(item, orgID)
}
return s.repo.UpdateLocation(item, orgID)
}

View File

@ -0,0 +1,31 @@
package service
import (
"testing"
"github.com/wmms/wmms/internal/model"
)
func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
user := &model.User{
ID: 7,
Username: "zhangsan",
Roles: []model.Role{
{ID: 2, Code: "warehouse_admin", Name: "仓库管理员", Enabled: true},
{ID: 3, Code: "purchaser", Name: "采购人员", Enabled: true},
},
}
service := NewAuthService()
tokenString, err := service.GenerateToken(user)
if err != nil {
t.Fatalf("generate token: %v", err)
}
token, err := service.ValidateToken(tokenString)
if err != nil {
t.Fatalf("validate token: %v", err)
}
if len(token.Roles) != 2 || token.Roles[0] != "warehouse_admin" || token.Roles[1] != "purchaser" {
t.Fatalf("unexpected token roles: %+v", token.Roles)
}
}

View File

@ -0,0 +1,108 @@
CREATE TABLE organizations (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(50) NOT NULL,
name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
default_timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_organizations_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO organizations (id, code, name, status)
VALUES (1, 'DEFAULT', '默认公司组织', 'active');
CREATE TABLE roles (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(80) NOT NULL,
name VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_roles_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE permissions (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(120) NOT NULL,
name VARCHAR(150) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_permissions_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_organizations (
user_id BIGINT NOT NULL,
organization_id BIGINT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, organization_id),
CONSTRAINT fk_user_organizations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_organizations_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE revoked_tokens (
jti VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (jti),
KEY idx_revoked_tokens_expires_at (expires_at),
CONSTRAINT fk_revoked_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO roles (code, name) VALUES
('super_admin', '超级管理员'),
('warehouse_admin', '仓库管理员'),
('purchaser', '采购人员'),
('technician', '技术人员'),
('production', '生产人员'),
('sales', '销售人员');
INSERT INTO permissions (code, name) VALUES
('master.material.read', '查看物料'),
('master.material.write', '维护物料'),
('master.config.write', '维护物料基础配置'),
('organization.read', '查看公司组织'),
('organization.write', '维护公司组织'),
('organization.user.write', '维护用户'),
('organization.role.write', '维护角色与权限'),
('partner.supplier.read', '查看供应商'),
('partner.supplier.write', '维护供应商'),
('partner.customer.read', '查看客户'),
('partner.customer.write', '维护客户'),
('inventory.stock.read', '查看库存'),
('inventory.warning.write', '维护库存预警'),
('inventory.inbound.write', '维护入库'),
('inventory.outbound.write', '维护出库');
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.code = 'super_admin';
INSERT INTO user_organizations (user_id, organization_id, is_default)
SELECT id, 1, TRUE FROM users;
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u
JOIN roles r ON r.code = CASE WHEN u.role = 'admin' THEN 'super_admin' ELSE 'warehouse_admin' END;

View File

@ -0,0 +1,128 @@
CREATE TABLE material_types (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (id),
UNIQUE KEY uk_material_types_org_code (organization_id, code),
CONSTRAINT fk_material_types_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_categories (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
parent_id BIGINT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (id),
UNIQUE KEY uk_material_categories_org_code (organization_id, code),
CONSTRAINT fk_material_categories_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_categories_parent FOREIGN KEY (parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE units (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
precision_scale TINYINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (id),
UNIQUE KEY uk_units_org_code (organization_id, code),
CONSTRAINT fk_units_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE code_rules (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
prefix VARCHAR(30) NOT NULL,
sequence_length TINYINT NOT NULL,
next_sequence BIGINT NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (id),
UNIQUE KEY uk_code_rules_org_category (organization_id, category_id),
CONSTRAINT fk_code_rules_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_code_rules_category FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_unit_conversions (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
from_unit_id BIGINT NOT NULL,
to_unit_id BIGINT NOT NULL,
conversion_rate DECIMAL(20,6) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE NULL,
PRIMARY KEY (id),
KEY idx_material_unit_conversions_material (organization_id, material_id),
CONSTRAINT fk_material_unit_conversions_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_unit_conversions_material FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_unit_conversions_from_unit FOREIGN KEY (from_unit_id) REFERENCES units(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_unit_conversions_to_unit FOREIGN KEY (to_unit_id) REFERENCES units(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE materials
ADD COLUMN organization_id BIGINT NOT NULL DEFAULT 1 AFTER id,
ADD COLUMN material_type_id BIGINT NULL AFTER category,
ADD COLUMN category_id BIGINT NULL AFTER material_type_id,
ADD COLUMN base_unit_id BIGINT NULL AFTER unit,
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active' AFTER base_unit_id,
ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER status,
ADD COLUMN manufacturer_model VARCHAR(200) NULL AFTER spec,
ADD CONSTRAINT fk_materials_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_materials_type FOREIGN KEY (material_type_id) REFERENCES material_types(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_materials_category FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_materials_base_unit FOREIGN KEY (base_unit_id) REFERENCES units(id) ON DELETE RESTRICT;
ALTER TABLE materials DROP INDEX material_code;
ALTER TABLE materials ADD UNIQUE KEY uk_materials_org_code (organization_id, material_code);
INSERT INTO material_types (organization_id, code, name) VALUES
(1, 'raw_material', '原材料'),
(1, 'semi_finished', '半成品'),
(1, 'finished_good', '成品'),
(1, 'consumable', '耗材'),
(1, 'service', '服务');
INSERT INTO material_categories (organization_id, code, name)
VALUES
(1, 'A01', '标准件'),
(1, 'A02', '成品件'),
(1, 'A03', '简易加工件'),
(1, 'A04', '精密加工件'),
(1, 'A05', '钣金件'),
(1, 'A06', '塑胶件'),
(1, 'A07', '其他'),
(1, 'B01', '成品件'),
(1, 'B02', '简易加工件'),
(1, 'B03', 'PCB硬板'),
(1, 'B04', 'FPC软板'),
(1, 'B05', '线束'),
(1, 'B06', '组装组件'),
(1, 'B07', '其他'),
(1, 'C01', '连接耗材'),
(1, 'C02', '密封耗材'),
(1, 'C03', '其他'),
(1, 'D01', '自研程序固件'),
(1, 'D02', '外购软件授权'),
(1, 'D03', '授权介质'),
(1, 'D04', '其他'),
(1, 'E01', '成品'),
(1, 'E02', '半成品'),
(1, 'F01', '资料文档'),
(1, 'F02', '标识标牌'),
(1, 'F03', '包装物料'),
(1, 'F04', '随机工具配件'),
(1, 'F05', '其他');
INSERT INTO units (organization_id, code, name)
SELECT 1, unit, unit FROM materials WHERE unit IS NOT NULL AND unit <> '' GROUP BY unit;
UPDATE materials m
LEFT JOIN material_categories c ON c.organization_id = m.organization_id AND c.code = m.category
LEFT JOIN units u ON u.organization_id = m.organization_id AND u.code = m.unit
SET m.category_id = c.id, m.base_unit_id = u.id;

View File

@ -0,0 +1,52 @@
CREATE TABLE business_partners (
id BIGINT NOT NULL AUTO_INCREMENT,
legal_name VARCHAR(200) NOT NULL,
unified_credit_code VARCHAR(50) NULL,
contact_name VARCHAR(100) NULL,
contact_phone VARCHAR(50) NULL,
address VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_business_partners_credit_code (unified_credit_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE organization_partners (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
business_partner_id BIGINT NOT NULL,
partner_code VARCHAR(50) NOT NULL,
is_supplier BOOLEAN NOT NULL DEFAULT FALSE,
is_customer BOOLEAN NOT NULL DEFAULT FALSE,
supplier_status VARCHAR(20) NULL,
customer_status VARCHAR(20) NULL,
settlement_currency CHAR(3) NULL,
payment_terms VARCHAR(200) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_organization_partners_org_partner (organization_id, business_partner_id),
UNIQUE KEY uk_organization_partners_org_code (organization_id, partner_code),
CONSTRAINT fk_organization_partners_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_partners_partner FOREIGN KEY (business_partner_id) REFERENCES business_partners(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_suppliers (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
organization_partner_id BIGINT NOT NULL,
price DECIMAL(20,6) NOT NULL,
currency CHAR(3) NOT NULL,
minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0,
lead_time_days INT NOT NULL DEFAULT 0,
is_preferred BOOLEAN NOT NULL DEFAULT FALSE,
effective_from DATE NOT NULL,
effective_to DATE NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (id),
KEY idx_material_suppliers_material (organization_id, material_id),
CONSTRAINT fk_material_suppliers_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_suppliers_material FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE RESTRICT,
CONSTRAINT fk_material_suppliers_partner FOREIGN KEY (organization_partner_id) REFERENCES organization_partners(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,43 @@
CREATE TABLE warehouses (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(150) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_warehouses_org_code (organization_id, code),
CONSTRAINT fk_warehouses_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE warehouse_locations (
id BIGINT NOT NULL AUTO_INCREMENT,
warehouse_id BIGINT NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(150) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_warehouse_locations_warehouse_code (warehouse_id, code),
CONSTRAINT fk_warehouse_locations_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_warehouses (
user_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, warehouse_id),
CONSTRAINT fk_user_warehouses_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_warehouses_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO warehouses (id, organization_id, code, name, status)
VALUES (1, 1, 'DEFAULT', '默认仓库', 'active');
INSERT INTO warehouse_locations (id, warehouse_id, code, name, status)
VALUES (1, 1, 'MAIN', '主库位', 'active');
INSERT INTO user_warehouses (user_id, warehouse_id)
SELECT id, 1 FROM users;

View File

@ -0,0 +1,36 @@
CREATE TABLE inventory_balances (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
location_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
on_hand DECIMAL(20,6) NOT NULL DEFAULT 0,
reserved DECIMAL(20,6) NOT NULL DEFAULT 0,
locked DECIMAL(20,6) NOT NULL DEFAULT 0,
in_transit DECIMAL(20,6) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_inventory_balance_dimension (organization_id, warehouse_id, location_id, material_id),
KEY idx_inventory_balances_material (organization_id, material_id),
CONSTRAINT fk_inventory_balances_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_balances_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_balances_location FOREIGN KEY (location_id) REFERENCES warehouse_locations(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_balances_material FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE stock_warning_rules (
id BIGINT NOT NULL AUTO_INCREMENT,
organization_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
minimum_available DECIMAL(20,6) NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_stock_warning_rules_dimension (organization_id, warehouse_id, material_id),
CONSTRAINT fk_stock_warning_rules_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
CONSTRAINT fk_stock_warning_rules_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT,
CONSTRAINT fk_stock_warning_rules_material FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,31 @@
INSERT INTO inventory_balances (organization_id, warehouse_id, location_id, material_id, on_hand)
SELECT 1, 1, 1, id, current_stock FROM materials
ON DUPLICATE KEY UPDATE material_id = VALUES(material_id);
INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled)
SELECT 1, 1, id, warning_stock, TRUE FROM materials
ON DUPLICATE KEY UPDATE material_id = VALUES(material_id);
ALTER TABLE inbound_orders
ADD COLUMN organization_id BIGINT NOT NULL DEFAULT 1 AFTER id,
ADD COLUMN warehouse_id BIGINT NOT NULL DEFAULT 1 AFTER organization_id,
ADD COLUMN location_id BIGINT NOT NULL DEFAULT 1 AFTER warehouse_id,
ADD CONSTRAINT fk_inbound_orders_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_inbound_orders_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_inbound_orders_location FOREIGN KEY (location_id) REFERENCES warehouse_locations(id) ON DELETE RESTRICT;
ALTER TABLE outbound_orders
ADD COLUMN organization_id BIGINT NOT NULL DEFAULT 1 AFTER id,
ADD COLUMN warehouse_id BIGINT NOT NULL DEFAULT 1 AFTER organization_id,
ADD COLUMN location_id BIGINT NOT NULL DEFAULT 1 AFTER warehouse_id,
ADD CONSTRAINT fk_outbound_orders_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_outbound_orders_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_outbound_orders_location FOREIGN KEY (location_id) REFERENCES warehouse_locations(id) ON DELETE RESTRICT;
SELECT m.id, m.material_code, m.current_stock AS legacy_stock,
COALESCE(SUM(ib.on_hand), 0) AS migrated_stock,
m.current_stock - COALESCE(SUM(ib.on_hand), 0) AS difference
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
GROUP BY m.id, m.material_code, m.current_stock
HAVING difference <> 0;

View File

@ -0,0 +1,3 @@
ALTER TABLE materials
DROP COLUMN current_stock,
DROP COLUMN warning_stock;

View File

@ -0,0 +1,31 @@
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM (
SELECT 'warehouse_admin' AS role_code, 'master.material.read' AS permission_code
UNION ALL SELECT 'warehouse_admin', 'inventory.stock.read'
UNION ALL SELECT 'warehouse_admin', 'inventory.warning.write'
UNION ALL SELECT 'warehouse_admin', 'inventory.inbound.write'
UNION ALL SELECT 'warehouse_admin', 'inventory.outbound.write'
UNION ALL SELECT 'purchaser', 'master.material.read'
UNION ALL SELECT 'purchaser', 'partner.supplier.read'
UNION ALL SELECT 'purchaser', 'partner.supplier.write'
UNION ALL SELECT 'purchaser', 'inventory.stock.read'
UNION ALL SELECT 'purchaser', 'inventory.warning.write'
UNION ALL SELECT 'technician', 'master.material.read'
UNION ALL SELECT 'technician', 'master.material.write'
UNION ALL SELECT 'technician', 'master.config.write'
UNION ALL SELECT 'technician', 'inventory.stock.read'
UNION ALL SELECT 'production', 'master.material.read'
UNION ALL SELECT 'production', 'inventory.stock.read'
UNION ALL SELECT 'production', 'inventory.outbound.write'
UNION ALL SELECT 'sales', 'master.material.read'
UNION ALL SELECT 'sales', 'partner.customer.read'
UNION ALL SELECT 'sales', 'partner.customer.write'
UNION ALL SELECT 'sales', 'inventory.stock.read'
) AS defaults
JOIN roles r ON r.code = defaults.role_code
JOIN permissions p ON p.code = defaults.permission_code;

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN role;

View File

@ -0,0 +1,96 @@
UPDATE material_categories
SET name = '外购件类'
WHERE code = 'B01' AND parent_id IS NULL;
INSERT INTO material_categories (organization_id, code, name, parent_id, status)
SELECT parent.organization_id,
CONCAT(parent.code, '-01'),
CASE parent.code
WHEN 'A01' THEN '紧固件类'
WHEN 'A02' THEN '轴承类'
WHEN 'A03' THEN '轴套法兰类'
WHEN 'A04' THEN '导轨丝杠类'
WHEN 'A05' THEN '机柜钣金类'
WHEN 'A06' THEN '管槽塑胶类'
WHEN 'B01' THEN '电子类'
WHEN 'B03' THEN '控制板类'
WHEN 'B04' THEN '排线类'
WHEN 'B05' THEN '电源线束类'
WHEN 'C01' THEN '接线辅材类'
WHEN 'C02' THEN '密封件类'
WHEN 'D01' THEN '控制固件类'
WHEN 'D02' THEN '工业软件类'
WHEN 'E01' THEN '电气设备类'
WHEN 'F01' THEN '技术资料类'
WHEN 'F02' THEN '设备标识类'
WHEN 'F03' THEN '包装容器类'
WHEN 'F04' THEN '手动工具类'
ELSE CONCAT(parent.name, '·通用型')
END,
parent.id,
parent.status
FROM material_categories parent
WHERE parent.parent_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM material_categories child
WHERE child.organization_id = parent.organization_id
AND child.parent_id = parent.id
);
INSERT INTO material_categories (organization_id, code, name, parent_id, status)
SELECT parent.organization_id,
CONCAT(parent.code, '-02'),
CASE parent.code
WHEN 'A01' THEN '传动连接类'
WHEN 'A02' THEN '其他成品件类'
WHEN 'A03' THEN '连接加工件类'
WHEN 'A04' THEN '齿轮传动类'
WHEN 'A05' THEN '管槽接头类'
WHEN 'A06' THEN '密封防护类'
WHEN 'B01' THEN '机电类'
WHEN 'B03' THEN '功能板类'
WHEN 'B04' THEN '柔性电缆类'
WHEN 'B05' THEN '信号线束类'
WHEN 'C01' THEN '包装连接类'
WHEN 'C02' THEN '密封材料类'
WHEN 'D01' THEN '升级包类'
WHEN 'D02' THEN '设计软件类'
WHEN 'E01' THEN '操作设备类'
WHEN 'F01' THEN '管理资料类'
WHEN 'F02' THEN '包装标识类'
WHEN 'F03' THEN '缓冲防护类'
WHEN 'F04' THEN '仪器仪表类'
ELSE CONCAT(parent.name, '·专用型')
END,
parent.id,
parent.status
FROM material_categories parent
WHERE parent.parent_id IS NULL
AND EXISTS (
SELECT 1 FROM material_categories first_child
WHERE first_child.organization_id = parent.organization_id
AND first_child.parent_id = parent.id
AND first_child.code = CONCAT(parent.code, '-01')
)
AND NOT EXISTS (
SELECT 1 FROM material_categories child
WHERE child.organization_id = parent.organization_id
AND child.code = CONCAT(parent.code, '-02')
);
UPDATE materials material
JOIN material_categories parent
ON parent.id = material.category_id
AND parent.organization_id = material.organization_id
AND parent.parent_id IS NULL
JOIN material_categories child
ON child.id = (
SELECT CASE
WHEN MOD(CRC32(material.material_code), 2) = 0 THEN MIN(candidate.id)
ELSE MAX(candidate.id)
END
FROM material_categories candidate
WHERE candidate.organization_id = parent.organization_id
AND candidate.parent_id = parent.id
)
SET material.category_id = child.id;

Binary file not shown.