Compare commits
No commits in common. "5a0376f3181763f3cdbe9aebaea880c46627f5f5" and "31aae223c27ed08d589f45af633f103abba1f63d" have entirely different histories.
5a0376f318
...
31aae223c2
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,9 +1,7 @@
|
||||||
.claude/
|
".claude/"
|
||||||
.gocache/
|
".gocache/"
|
||||||
.superpowers/
|
".superpowers/"
|
||||||
node_modules/
|
"node_modules/"
|
||||||
*.exe~
|
"*.exe~"
|
||||||
nul
|
"nul"
|
||||||
server.exe
|
"server.exe"
|
||||||
warehouse-mms.exe
|
|
||||||
frontend/dist/
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# 第一阶段数据库迁移
|
|
||||||
|
|
||||||
本阶段将公司组织、业务伙伴、仓库/库位、物料基础配置和库存余额拆分到独立表。旧的
|
|
||||||
`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`。
|
|
||||||
|
|
||||||
不要在生产库直接试跑。应先在数据库副本验证一次;当前自动化测试只验证迁移执行器和应用查询,
|
|
||||||
不替代生产数据副本的迁移核对。
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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')
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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 }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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: '物料申请' }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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 })
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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) }))
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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(['物料主数据', '库存管理'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<template><PartnerList role="customer" title="客户" /></template>
|
|
||||||
<script setup>
|
|
||||||
import PartnerList from './PartnerList.vue'
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<template><PartnerList role="supplier" title="供应商" /></template>
|
|
||||||
<script setup>
|
|
||||||
import PartnerList from './PartnerList.vue'
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE materials
|
|
||||||
DROP COLUMN current_stock,
|
|
||||||
DROP COLUMN warning_stock;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
ALTER TABLE users DROP COLUMN role;
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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