Compare commits
2 Commits
31aae223c2
...
5a0376f318
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0376f318 | ||
|
|
a103549854 |
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,7 +1,9 @@
|
||||||
".claude/"
|
.claude/
|
||||||
".gocache/"
|
.gocache/
|
||||||
".superpowers/"
|
.superpowers/
|
||||||
"node_modules/"
|
node_modules/
|
||||||
"*.exe~"
|
*.exe~
|
||||||
"nul"
|
nul
|
||||||
"server.exe"
|
server.exe
|
||||||
|
warehouse-mms.exe
|
||||||
|
frontend/dist/
|
||||||
|
|
|
||||||
31
docs/phase1-migration.md
Normal file
31
docs/phase1-migration.md
Normal 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`。
|
||||||
|
|
||||||
|
不要在生产库直接试跑。应先在数据库副本验证一次;当前自动化测试只验证迁移执行器和应用查询,
|
||||||
|
不替代生产数据副本的迁移核对。
|
||||||
19
frontend/src/api/masterData.js
Normal file
19
frontend/src/api/masterData.js
Normal 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')
|
||||||
22
frontend/src/api/organizations.js
Normal file
22
frontend/src/api/organizations.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/api/partners.js
Normal file
13
frontend/src/api/partners.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/api/warehouses.js
Normal file
9
frontend/src/api/warehouses.js
Normal 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) }
|
||||||
|
}
|
||||||
60
frontend/src/navigation/menu.js
Normal file
60
frontend/src/navigation/menu.js
Normal 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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/navigation/menu.test.js
Normal file
43
frontend/src/navigation/menu.test.js
Normal 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: '物料申请' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
24
frontend/src/router/routes.test.js
Normal file
24
frontend/src/router/routes.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
32
frontend/src/stores/auth.test.js
Normal file
32
frontend/src/stores/auth.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
44
frontend/src/stores/inbound.test.js
Normal file
44
frontend/src/stores/inbound.test.js
Normal 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 })
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
36
frontend/src/stores/stock.test.js
Normal file
36
frontend/src/stores/stock.test.js
Normal 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
36
frontend/src/utils/materialCategories.js
Normal file
36
frontend/src/utils/materialCategories.js
Normal 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 }
|
||||||
|
}
|
||||||
38
frontend/src/utils/materialCategories.test.js
Normal file
38
frontend/src/utils/materialCategories.test.js
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
36
frontend/src/utils/quantity.js
Normal file
36
frontend/src/utils/quantity.js
Normal 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
|
||||||
|
}
|
||||||
14
frontend/src/utils/quantity.test.js
Normal file
14
frontend/src/utils/quantity.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
93
frontend/src/views/Users.test.js
Normal file
93
frontend/src/views/Users.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
47
frontend/src/views/inventory/WarehouseSettings.vue
Normal file
47
frontend/src/views/inventory/WarehouseSettings.vue
Normal 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>
|
||||||
168
frontend/src/views/master-data/MasterDataSettings.vue
Normal file
168
frontend/src/views/master-data/MasterDataSettings.vue
Normal 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>
|
||||||
35
frontend/src/views/materialForm.js
Normal file
35
frontend/src/views/materialForm.js
Normal 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
|
||||||
|
}
|
||||||
56
frontend/src/views/materialForm.test.js
Normal file
56
frontend/src/views/materialForm.test.js
Normal 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
53
frontend/src/views/organizations/Organizations.vue
Normal file
53
frontend/src/views/organizations/Organizations.vue
Normal 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>
|
||||||
44
frontend/src/views/organizations/Roles.test.js
Normal file
44
frontend/src/views/organizations/Roles.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
127
frontend/src/views/organizations/Roles.vue
Normal file
127
frontend/src/views/organizations/Roles.vue
Normal 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>
|
||||||
28
frontend/src/views/organizations/rolePermissions.js
Normal file
28
frontend/src/views/organizations/rolePermissions.js
Normal 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) }))
|
||||||
|
}
|
||||||
17
frontend/src/views/organizations/rolePermissions.test.js
Normal file
17
frontend/src/views/organizations/rolePermissions.test.js
Normal 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(['物料主数据', '库存管理'])
|
||||||
|
})
|
||||||
|
})
|
||||||
4
frontend/src/views/partners/Customers.vue
Normal file
4
frontend/src/views/partners/Customers.vue
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<template><PartnerList role="customer" title="客户" /></template>
|
||||||
|
<script setup>
|
||||||
|
import PartnerList from './PartnerList.vue'
|
||||||
|
</script>
|
||||||
76
frontend/src/views/partners/PartnerList.vue
Normal file
76
frontend/src/views/partners/PartnerList.vue
Normal 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>
|
||||||
4
frontend/src/views/partners/Suppliers.vue
Normal file
4
frontend/src/views/partners/Suppliers.vue
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<template><PartnerList role="supplier" title="供应商" /></template>
|
||||||
|
<script setup>
|
||||||
|
import PartnerList from './PartnerList.vue'
|
||||||
|
</script>
|
||||||
402
internal/handler/admin_master.go
Normal file
402
internal/handler/admin_master.go
Normal 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)
|
||||||
|
}
|
||||||
37
internal/handler/routes_test.go
Normal file
37
internal/handler/routes_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/middleware/auth_test.go
Normal file
43
internal/middleware/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/model/inventory.go
Normal file
32
internal/model/inventory.go
Normal 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"`
|
||||||
|
}
|
||||||
31
internal/model/master_data.go
Normal file
31
internal/model/master_data.go
Normal 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"`
|
||||||
|
}
|
||||||
35
internal/model/organization.go
Normal file
35
internal/model/organization.go
Normal 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
19
internal/model/partner.go
Normal 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"`
|
||||||
|
}
|
||||||
18
internal/model/warehouse.go
Normal file
18
internal/model/warehouse.go
Normal 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"`
|
||||||
|
}
|
||||||
105
internal/repository/master_data.go
Normal file
105
internal/repository/master_data.go
Normal 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
|
||||||
|
}
|
||||||
177
internal/repository/organization.go
Normal file
177
internal/repository/organization.go
Normal 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
|
||||||
|
}
|
||||||
94
internal/repository/organization_test.go
Normal file
94
internal/repository/organization_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
internal/repository/partner.go
Normal file
122
internal/repository/partner.go
Normal 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
|
||||||
|
}
|
||||||
51
internal/repository/partner_test.go
Normal file
51
internal/repository/partner_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/repository/user_test.go
Normal file
82
internal/repository/user_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/repository/warehouse.go
Normal file
75
internal/repository/warehouse.go
Normal 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
|
||||||
|
}
|
||||||
196
internal/service/admin_master.go
Normal file
196
internal/service/admin_master.go
Normal 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)
|
||||||
|
}
|
||||||
31
internal/service/auth_test.go
Normal file
31
internal/service/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
scripts/migrations/001_phase1_auth_org.sql
Normal file
108
scripts/migrations/001_phase1_auth_org.sql
Normal 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;
|
||||||
128
scripts/migrations/002_phase1_master_data.sql
Normal file
128
scripts/migrations/002_phase1_master_data.sql
Normal 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;
|
||||||
52
scripts/migrations/003_phase1_partners.sql
Normal file
52
scripts/migrations/003_phase1_partners.sql
Normal 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;
|
||||||
43
scripts/migrations/004_phase1_warehouses.sql
Normal file
43
scripts/migrations/004_phase1_warehouses.sql
Normal 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;
|
||||||
36
scripts/migrations/005_phase1_inventory_split.sql
Normal file
36
scripts/migrations/005_phase1_inventory_split.sql
Normal 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;
|
||||||
31
scripts/migrations/006_phase1_backfill.sql
Normal file
31
scripts/migrations/006_phase1_backfill.sql
Normal 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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE materials
|
||||||
|
DROP COLUMN current_stock,
|
||||||
|
DROP COLUMN warning_stock;
|
||||||
31
scripts/migrations/006_phase1_role_permission_defaults.sql
Normal file
31
scripts/migrations/006_phase1_role_permission_defaults.sql
Normal 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;
|
||||||
1
scripts/migrations/006_phase1_user_multi_roles.sql
Normal file
1
scripts/migrations/006_phase1_user_multi_roles.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users DROP COLUMN role;
|
||||||
96
scripts/migrations/007_material_subcategories.sql
Normal file
96
scripts/migrations/007_material_subcategories.sql
Normal 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;
|
||||||
BIN
server.exe
BIN
server.exe
Binary file not shown.
Loading…
Reference in New Issue
Block a user