restore lost frontend and backend changes

This commit is contained in:
文睿 张 2026-09-16 23:30:25 +08:00
parent 5a0376f318
commit 1cc3584f1a
38 changed files with 1554 additions and 786 deletions

View File

@ -33,27 +33,27 @@
当前系统具备物料、简单入库、简单出库、库存查询、物料申请、用户登录和日志页面,但与 v2.3 存在以下结构性差距: 当前系统具备物料、简单入库、简单出库、库存查询、物料申请、用户登录和日志页面,但与 v2.3 存在以下结构性差距:
| 当前实现 | v2.3 目标 | 处理阶段 | | 当前实现 | v2.3 目标 | 处理阶段 |
| --------------------------------- | --------------------- | --------- | | --------------------------------- | ------------------------------- | --------- |
| 用户只有一个 `admin/user` 枚举角色 | 用户与角色多对多,权限和数据范围分别控制 | 第一阶段 | | 用户只有一个 `admin/user` 枚举角色 | 用户与角色多对多,权限和数据范围分别控制 | 第一阶段 |
| 自制固定密钥 Token,权限依赖 Token 中的单角色 | 标准 JWT,按请求加载最新角色和数据范围 | 第一阶段 | | 自制固定密钥 Token,权限依赖 Token 中的单角色 | 标准 JWT,按请求加载最新角色和数据范围 | 第一阶段 |
| 物料编码全局唯一 | 同一经营组织内唯一 | 第一阶段 | | 物料编码全局唯一 | 同一经营组织内唯一 | 第一阶段 |
| 分类和单位为自由文本 | 分类、物料类型、单位及换算为受控主数据 | 第一阶段 | | 分类和单位为自由文本 | 分类、物料类型、单位及换算为受控主数据 | 第一阶段 |
| 没有业务伙伴、组织、仓库和库位实体 | 完整主数据及停用规则 | 第一阶段 | | 没有业务伙伴、组织、仓库和库位实体 | 完整主数据及停用规则 | 第一阶段 |
| 库存直接保存在 `materials.current_stock` | 第一阶段拆分独立库存余额,第二阶段启用不可变库存流水和过账内核 | 第一、二阶段 | | 库存直接保存在 `materials.current_stock` | 第一阶段拆分独立库存余额,第二阶段启用不可变库存流水和过账内核 | 第一、二阶段 |
| 创建或删除入出库单直接增减库存 | 草稿、提交、过账、撤销过账状态机 | 第二阶段 | | 创建或删除入出库单直接增减库存 | 草稿、提交、过账、撤销过账状态机 | 第二阶段 |
| 没有调拨、盘点、预留和通知闭环 | 完整仓储执行闭环 | 第二阶段 | | 没有调拨、盘点、预留和通知闭环 | 完整仓储执行闭环 | 第二阶段 |
| 没有 BOM 和制造订单 | BOM 版本、展开、校验和制造执行 | 第三阶段 | | 没有 BOM 和制造订单 | BOM 版本、展开、校验和制造执行 | 第三阶段 |
| 没有 Excel 任务和 SSO | 受控批量任务及 OIDC SSO | 第三阶段 | | 没有 Excel 任务和 SSO | 受控批量任务及 OIDC SSO | 第三阶段 |
| 没有自动化测试 | 后端单元/集成测试与前端组件测试 | 第一阶段起持续建设 | | 没有自动化测试 | 后端单元/集成测试与前端组件测试 | 第一阶段起持续建设 |
## 二、三次交付边界 ## 二、三次交付边界
| 阶段 | 交付主题 | 覆盖故事 | 可独立上线的业务结果 | | 阶段 | 交付主题 | 覆盖故事 | 可独立上线的业务结果 |
| ---- | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ | | ---- | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| 第一阶段 | 组织、权限和主数据底座 | ORG-001、CODE-001~004、MD-001~008、COM-001~005、WH-001~003、STOCK-003、AUTH-001、AUTH-003~006、AUD-001 | 用户可按多角色登录,只能访问授权组织/仓库;物料主数据与库存余额分表;可维护编码、物料、业务伙伴、仓库和库位 | | 第一阶段 | 组织、权限和主数据底座 | ORG-001、CODE-001~004、MD-001~008、COM-001~005、WH-001~003、STOCK-003、AUTH-001、AUTH-003~006、AUD-001 | 用户可按多角色登录,只能访问授权组织/仓库;物料主数据与库存余额分表;可维护编码、物料、业务伙伴、仓库和库位 |
| 第二阶段 | 仓储业务闭环 | STOCK-001~002、STOCK-004~009、NOTIFY-001 | 入库、出库、调拨、盘点、预留、撤销过账、库存流水和预警形成完整闭环 | | 第二阶段 | 仓储业务闭环 | STOCK-001~002、STOCK-004~009、NOTIFY-001 | 入库、出库、调拨、盘点、预留、撤销过账、库存流水和预警形成完整闭环 |
| 第三阶段 | BOM、制造、批量和 SSO | BOM-001~010、BAT-001~004、AUTH-002 | 可维护受控 BOM,分批领料和完工;支持受控 Excel 任务和 OIDC 单点登录 | | 第三阶段 | BOM、制造、批量和 SSO | BOM-001~010、BAT-001~004、AUTH-002 | 可维护受控 BOM,分批领料和完工;支持受控 Excel 任务和 OIDC 单点登录 |
### 阶段依赖 ### 阶段依赖

View File

@ -17,6 +17,7 @@ request.interceptors.request.use(
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
console.log('API请求:', config.method?.toUpperCase(), config.url)
return config return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
@ -38,6 +39,25 @@ request.interceptors.response.use(
localStorage.removeItem('wmms_user') localStorage.removeItem('wmms_user')
router.push('/login') router.push('/login')
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
} else if (error.response?.status === 422) {
// Validation errors - parse and show friendly message
const data = error.response?.data
let msg = data?.message || ''
if (data?.error && typeof data.error === 'string') {
// Go validator errors format: "Key: 'Field' Error:Field validation ..."
const matches = data.error.match(/Error: (Field validation.*?)(\n|$)/gi)
if (matches) {
const fields = matches.map(m => {
const fieldMatch = m.match(/Key: '([^']+)'/)
return fieldMatch ? fieldMatch[1] : m
})
const labels = { Handler: '经手人', Supplier: '供应商', Receiver: '接收方' }
msg = fields.map(f => labels[f] || f).join('、') + '不能为空'
}
}
ElMessage.error(msg || '请检查必填字段')
} else if (error.response?.data?.message) {
ElMessage.error(error.response.data.message)
} else { } else {
ElMessage.error(error.message || '网络错误') ElMessage.error(error.message || '网络错误')
} }

View File

@ -13,8 +13,11 @@ export const materialsApi = {
update(id, data) { update(id, data) {
return request.put(`/materials/${id}`, data) return request.put(`/materials/${id}`, data)
}, },
delete(id) { obsolete(id) {
return request.delete(`/materials/${id}`) return request.post(`/materials/${id}/obsolete`)
},
restore(id) {
return request.post(`/materials/${id}/restore`)
}, },
uploadImage(formData) { uploadImage(formData) {
return request.post('/upload', formData, { return request.post('/upload', formData, {

View File

@ -12,11 +12,5 @@ export const organizationsApi = {
}, },
listRoles() { listRoles() {
return request.get('/roles') return request.get('/roles')
},
listPermissions() {
return request.get('/permissions')
},
updateRole(id, data) {
return request.put(`/roles/${id}`, data)
} }
} }

View File

@ -15,5 +15,11 @@ export const stockApi = {
}, },
getLogs(materialId, params = {}) { getLogs(materialId, params = {}) {
return request.get(`/stock/logs/${materialId}`, { params }) return request.get(`/stock/logs/${materialId}`, { params })
},
getInventoryBalanceDetails(materialId) {
return request.get(`/stock/${materialId}/details`)
},
updateWarning(materialId, warningStock) {
return request.put(`/stock/${materialId}/warning`, { warning_stock: warningStock })
} }
} }

View File

@ -1,9 +1,9 @@
<template> <template>
<el-dialog v-model="visible" title="确认删除" width="400px"> <el-dialog v-model="visible" :title="title" width="400px">
<p>{{ content }}</p> <p>{{ content }}</p>
<template #footer> <template #footer>
<el-button @click="visible = false">取消</el-button> <el-button @click="visible = false">取消</el-button>
<el-button type="danger" @click="handleConfirm" :loading="loading">确认删除</el-button> <el-button type="danger" @click="handleConfirm" :loading="loading">{{ confirmText }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@ -13,6 +13,14 @@ import { ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: Boolean, modelValue: Boolean,
title: {
type: String,
default: '确认删除'
},
confirmText: {
type: String,
default: '确认删除'
},
content: { content: {
type: String, type: String,
default: '确定要删除此数据吗?此操作不可恢复。' default: '确定要删除此数据吗?此操作不可恢复。'

View File

@ -4,12 +4,14 @@
class="app-sidebar" class="app-sidebar"
@select="handleSelect" @select="handleSelect"
> >
<el-menu-item <el-sub-menu v-for="section in navigation.sections" :key="section.key" :index="section.key">
v-for="item in visibleMenuItems" <template #title>{{ section.label }}</template>
:key="item.path" <el-menu-item v-for="item in section.items" :key="item.path" :index="item.path">
:index="item.path" {{ item.label }}
> </el-menu-item>
<span>{{ item.label }}</span> </el-sub-menu>
<el-menu-item v-for="item in navigation.standalone" :key="item.path" :index="item.path">
{{ item.label }}
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
</template> </template>
@ -17,6 +19,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { getNavigationMenu } from '../../navigation/menu'
const props = defineProps({ const props = defineProps({
activeMenu: String activeMenu: String
@ -26,23 +29,7 @@ const emit = defineEmits(['menu-select'])
const authStore = useAuthStore() const authStore = useAuthStore()
const menuItems = [ const navigation = computed(() => getNavigationMenu({ isAdmin: authStore.isAdmin }))
{ path: '/materials', label: '物料主数据管理', adminOnly: true },
{ path: '/inbound', label: '入库管理', adminOnly: true },
{ path: '/outbound', label: '出库管理', adminOnly: true },
{ path: '/stock', label: '库存查询', adminOnly: false },
{ path: '/application', label: '物料申请', adminOnly: false },
{ path: '/approval', label: '申请审批', adminOnly: true },
{ path: '/logs', label: '日志记录', adminOnly: true },
{ path: '/users', label: '用户管理', adminOnly: true }
]
const visibleMenuItems = computed(() => {
return menuItems.filter(item => {
if (item.adminOnly) return authStore.isAdmin
return true
})
})
function handleSelect(path) { function handleSelect(path) {
emit('menu-select', path) emit('menu-select', path)
@ -52,5 +39,6 @@ function handleSelect(path) {
<style scoped> <style scoped>
.app-sidebar { .app-sidebar {
height: 100%; height: 100%;
overflow-y: auto;
} }
</style> </style>

View File

@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const routes = [ export const routes = [
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
@ -18,6 +18,36 @@ const routes = [
component: () => import('../views/Materials.vue'), component: () => import('../views/Materials.vue'),
meta: { requiresAuth: true, adminOnly: true } meta: { requiresAuth: true, adminOnly: true }
}, },
{
path: '/master-data/settings',
name: 'MasterDataSettings',
component: () => import('../views/master-data/MasterDataSettings.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/organizations',
name: 'Organizations',
component: () => import('../views/organizations/Organizations.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/roles',
name: 'Roles',
component: () => import('../views/organizations/Roles.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/partners/suppliers',
name: 'Suppliers',
component: () => import('../views/partners/Suppliers.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{
path: '/partners/customers',
name: 'Customers',
component: () => import('../views/partners/Customers.vue'),
meta: { requiresAuth: true, adminOnly: true }
},
{ {
path: '/inbound', path: '/inbound',
name: 'Inbound', name: 'Inbound',

View File

@ -7,7 +7,8 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(JSON.parse(localStorage.getItem('wmms_user') || 'null')) const user = ref(JSON.parse(localStorage.getItem('wmms_user') || 'null'))
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin') const hasRole = roleCode => (user.value?.roles || []).some(role => role.enabled && role.code === roleCode)
const isAdmin = computed(() => hasRole('super_admin'))
function saveAuth(newToken, newUser) { function saveAuth(newToken, newUser) {
token.value = newToken token.value = newToken
@ -33,7 +34,7 @@ export const useAuthStore = defineStore('auth', () => {
if (!token.value) return null if (!token.value) return null
try { try {
const res = await authApi.getCurrentUser() const res = await authApi.getCurrentUser()
user.value = res.data saveAuth(token.value, res.data)
return res.data return res.data
} catch { } catch {
clearAuth() clearAuth()
@ -46,7 +47,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
return { return {
token, user, isLoggedIn, isAdmin, token, user, isLoggedIn, isAdmin, hasRole,
saveAuth, clearAuth, login, fetchCurrentUser, logout saveAuth, clearAuth, login, fetchCurrentUser, logout
} }
}) })

View File

@ -40,8 +40,13 @@ export const useInboundStore = defineStore('inbound', () => {
return res return res
} }
async function fetchInboundDetail(id) {
const res = await inboundApi.getById(id)
return res.data || res
}
return { return {
inboundList, loading, page, inboundList, loading, page,
fetchInboundList, createInbound, updateInbound, deleteInbound fetchInboundList, createInbound, updateInbound, deleteInbound, fetchInboundDetail
} }
}) })

View File

@ -51,14 +51,20 @@ export const useMaterialsStore = defineStore('materials', () => {
return res return res
} }
async function deleteMaterial(id) { async function obsoleteMaterial(id) {
const res = await materialsApi.delete(id) const res = await materialsApi.obsolete(id)
await fetchMaterials(pagination.value.currentPage)
return res
}
async function restoreMaterial(id) {
const res = await materialsApi.restore(id)
await fetchMaterials(pagination.value.currentPage) await fetchMaterials(pagination.value.currentPage)
return res return res
} }
return { return {
materials, loading, pagination, categoryMap, materials, loading, pagination, categoryMap,
getCategoryDisplay, fetchMaterials, createMaterial, updateMaterial, deleteMaterial getCategoryDisplay, fetchMaterials, createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial
} }
}) })

View File

@ -42,7 +42,7 @@ export const useOutboundStore = defineStore('outbound', () => {
async function fetchOutboundDetail(id) { async function fetchOutboundDetail(id) {
const res = await outboundApi.getById(id) const res = await outboundApi.getById(id)
return res return res.data || res
} }
return { return {

View File

@ -84,9 +84,18 @@ export const useStockStore = defineStore('stock', () => {
} }
} }
async function updateWarningStock(materialId, warningStock) {
await stockApi.updateWarning(materialId, warningStock)
await Promise.all([
fetchStock(stockPage.value.currentPage),
fetchStockWarning(warningPage.value.currentPage)
])
}
return { return {
stockList, warningList, logs, loading, stockList, warningList, logs, loading,
stockPage, warningPage, logsPage, stockPage, warningPage, logsPage,
fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockWarning, fetchStockLogs fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockWarning, fetchStockLogs,
updateWarningStock
} }
}) })

View File

@ -4,3 +4,4 @@ config.global.stubs = {
transition: false, transition: false,
'transition-group': false 'transition-group': false
} }
config.global.renderStubDefaultSlot = true

View File

@ -29,12 +29,12 @@
{{ formatDate(row.created_at) }} {{ formatDate(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160"> <el-table-column prop="updated_at" label="更新时间" width="160" class-name="pr-3">
<template #default="{ row }"> <template #default="{ row }">
{{ formatDate(row.updated_at) }} {{ formatDate(row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right" class-name="border-left">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button> <el-button size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button> <el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
@ -167,8 +167,16 @@ function showEditModal(row) {
dialogVisible.value = true dialogVisible.value = true
} }
function showDetail(row) { async function showDetail(row) {
currentRow.value = row try {
const detail = await inboundStore.fetchInboundDetail(row.id)
currentRow.value = detail
} catch (error) {
console.error('获取详情失败:', error)
const msg = error.response?.data?.message || error.message || '获取详情失败'
ElMessage.error(msg)
currentRow.value = { ...row, items: [] }
}
detailVisible.value = true detailVisible.value = true
} }
@ -255,4 +263,10 @@ function formatDate(dateStr) {
.mt-3 { .mt-3 {
margin-top: 16px; margin-top: 16px;
} }
:deep(.pr-3) {
padding-right: 12px;
}
:deep(.border-left) {
border-left: 1px solid var(--el-table-border-color);
}
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="materials-page"> <div class="materials-page">
<div class="page-header"> <div class="page-header">
<h2>物料管理</h2> <h2>物料主数据</h2>
<el-button type="primary" @click="showAddModal">新增物料</el-button> <el-button type="primary" @click="showAddModal">新增物料</el-button>
</div> </div>
@ -24,39 +24,32 @@
:total="materialsStore.pagination.total" :total="materialsStore.pagination.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_code" label="物料编码" /> <el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="name" label="名称" /> <el-table-column label="大类类型" min-width="105">
<el-table-column prop="material_image" label="物料图片" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-image {{ getMajorCategoryDisplay(row) }}
v-if="row.material_image"
:src="row.material_image"
style="width: 40px; height: 40px; object-fit: contain"
:preview-src-list="[row.material_image]"
fit="contain"
/>
<span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="小类类型" min-width="91">
<template #default="{ row }">
{{ getMinorCategoryDisplay(row) }}
</template>
</el-table-column>
<el-table-column prop="name" label="物料名称" />
<el-table-column prop="spec" label="规格" /> <el-table-column prop="spec" label="规格" />
<el-table-column prop="category" label="分类">
<template #default="{ row }">
{{ materialsStore.getCategoryDisplay(row.category) }}
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" /> <el-table-column prop="unit" label="单位" width="60" />
<el-table-column prop="warning_stock" label="预警库存" width="100" /> <el-table-column
<el-table-column prop="created_at" label="创建时间" width="160"> label="操作"
<template #default="{ row }"> width="240"
{{ formatDate(row.created_at) }} fixed="right"
</template> class-name="operation-column"
</el-table-column> label-class-name="operation-column"
<el-table-column label="操作" width="240" fixed="right"> >
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button> <el-button size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button> <el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
<el-button v-if="authStore.isAdmin" size="small" type="danger" @click="handleDelete(row.id)">删除</el-button> <el-button v-if="authStore.isAdmin && row.status !== 'obsolete'" size="small" type="danger" @click="handleDelete(row.id)">作废</el-button>
<el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销</el-button>
</template> </template>
</el-table-column> </el-table-column>
</BaseTable> </BaseTable>
@ -72,43 +65,20 @@
<el-form-item label="规格"> <el-form-item label="规格">
<el-input v-model="form.spec" /> <el-input v-model="form.spec" />
</el-form-item> </el-form-item>
<el-form-item label="分类"> <el-form-item label="大类类型" required>
<el-select v-model="form.category" style="width: 100%"> <el-select v-model="form.major_category_id" style="width: 100%" @change="syncMajorCategory">
<el-option label="标准件" value="A01" /> <el-option v-for="category in majorCategories" :key="category.id" :label="`${category.code} ${category.name}`" :value="category.id" />
<el-option label="成品件" value="A02" /> </el-select>
<el-option label="简易加工件" value="A03" /> </el-form-item>
<el-option label="精密加工件" value="A04" /> <el-form-item label="小类类型" required>
<el-option label="钣金件" value="A05" /> <el-select v-model="form.category_id" :disabled="!form.major_category_id" style="width: 100%" @change="syncCategory">
<el-option label="塑胶件" value="A06" /> <el-option v-for="category in minorCategories" :key="category.id" :label="category.name" :value="category.id" />
<el-option label="其他" value="A07" />
<el-option label="成品件" value="B01" />
<el-option label="简易加工件" value="B02" />
<el-option label="PCB硬板" value="B03" />
<el-option label="FPC软板" value="B04" />
<el-option label="线束" value="B05" />
<el-option label="组装组件" value="B06" />
<el-option label="其他" value="B07" />
<el-option label="连接耗材" value="C01" />
<el-option label="密封耗材" value="C02" />
<el-option label="其他" value="C03" />
<el-option label="自研程序固件" value="D01" />
<el-option label="外购软件授权" value="D02" />
<el-option label="授权介质" value="D03" />
<el-option label="其他" value="D04" />
<el-option label="成品" value="E01" />
<el-option label="半成品" value="E02" />
<el-option label="资料文档" value="F01" />
<el-option label="标识标牌" value="F02" />
<el-option label="包装物料" value="F03" />
<el-option label="随机工具配件" value="F04" />
<el-option label="其他" value="F05" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="单位"> <el-form-item label="单位">
<el-input v-model="form.unit" /> <el-select v-model="form.base_unit_id" style="width: 100%" @change="syncUnit">
</el-form-item> <el-option v-for="unit in units" :key="unit.id" :label="unit.code === unit.name ? unit.code : `${unit.code} - ${unit.name}`" :value="unit.id" />
<el-form-item label="预警库存"> </el-select>
<el-input-number v-model="form.warning_stock" :min="0" />
</el-form-item> </el-form-item>
<el-form-item label="图片URL"> <el-form-item label="图片URL">
<el-input v-model="form.material_image" placeholder="图片URL" /> <el-input v-model="form.material_image" placeholder="图片URL" />
@ -127,12 +97,12 @@
<el-dialog v-model="detailVisible" title="物料详情" width="600px"> <el-dialog v-model="detailVisible" title="物料详情" width="600px">
<el-descriptions :column="2" border v-if="detailRow"> <el-descriptions :column="2" border v-if="detailRow">
<el-descriptions-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item> <el-descriptions-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item>
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item> <el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
<el-descriptions-item label="规格">{{ detailRow.spec || '-' }}</el-descriptions-item> <el-descriptions-item label="规格">{{ detailRow.spec || '-' }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ materialsStore.getCategoryDisplay(detailRow.category) }}</el-descriptions-item> <el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ detailRow.unit }}</el-descriptions-item> <el-descriptions-item label="单位">{{ detailRow.unit }}</el-descriptions-item>
<el-descriptions-item label="预警库存">{{ detailRow.warning_stock }}</el-descriptions-item>
<el-descriptions-item label="当前库存">{{ detailRow.current_stock }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="物料图片" :span="2"> <el-descriptions-item label="物料图片" :span="2">
@ -150,6 +120,9 @@
<DeleteConfirm <DeleteConfirm
v-model="deleteDialogVisible" v-model="deleteDialogVisible"
title="确认作废该物料?"
content="作废后该物料不能再用于新业务;已有业务记录仍会保留。"
confirm-text="确认作废"
@confirm="confirmDelete" @confirm="confirmDelete"
/> />
@ -158,13 +131,16 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { computed, ref, reactive, onMounted } from 'vue'
import { useMaterialsStore } from '../stores/materials' import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import AlertTip from '../components/business/AlertTip.vue' import AlertTip from '../components/business/AlertTip.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { createMaterialForm, toMaterialPayload } from './materialForm'
import { materialCategoriesApi, unitsApi } from '../api/masterData'
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -182,6 +158,10 @@ const alertMessage = ref('')
const alertType = ref('success') const alertType = ref('success')
const imagePreview = ref('') const imagePreview = ref('')
const imageInput = ref(null) const imageInput = ref(null)
const categories = ref([])
const units = ref([])
const majorCategories = computed(() => getMajorCategories(categories.value))
const minorCategories = computed(() => getMinorCategories(categories.value, form.major_category_id))
function handleImageUpload(e) { function handleImageUpload(e) {
const file = e.target.files[0] const file = e.target.files[0]
@ -190,19 +170,37 @@ function handleImageUpload(e) {
} }
} }
const form = reactive({ const form = reactive(createMaterialForm())
material_code: '',
name: '', onMounted(async () => {
spec: '', materialsStore.fetchMaterials()
category: '', const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
unit: 'pcs', categories.value = (categoryResponse.data || []).filter(item => item.status === 'active')
warning_stock: 0, units.value = (unitResponse.data || []).filter(item => item.status === 'active')
material_image: ''
}) })
onMounted(() => { function syncCategory(id) {
materialsStore.fetchMaterials() form.category = categories.value.find(item => item.id === id)?.code || ''
}) }
function syncMajorCategory() {
form.category_id = null
form.category = ''
}
function getMajorCategoryDisplay(row) {
const { major } = resolveCategoryPath(categories.value, row.category_id)
if (major) return `${major.code} ${major.name}`
return materialsStore.getCategoryDisplay(row.category)
}
function getMinorCategoryDisplay(row) {
return resolveCategoryPath(categories.value, row.category_id).minor?.name || '-'
}
function syncUnit(id) {
form.unit = units.value.find(item => item.id === id)?.code || ''
}
function handleSearch() { function handleSearch() {
materialsStore.fetchMaterials(1, searchKeyword.value) materialsStore.fetchMaterials(1, searchKeyword.value)
@ -215,15 +213,7 @@ function handlePageChange(page) {
function showAddModal() { function showAddModal() {
editingId.value = null editingId.value = null
dialogTitle.value = '新增物料' dialogTitle.value = '新增物料'
Object.assign(form, { Object.assign(form, createMaterialForm())
material_code: '',
name: '',
spec: '',
category: '',
unit: 'pcs',
warning_stock: 0,
material_image: ''
})
imagePreview.value = '' imagePreview.value = ''
if (imageInput.value) imageInput.value.value = '' if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true dialogVisible.value = true
@ -237,24 +227,20 @@ function showDetail(row) {
function showEditModal(row) { function showEditModal(row) {
editingId.value = row.id editingId.value = row.id
dialogTitle.value = '编辑物料' dialogTitle.value = '编辑物料'
Object.assign(form, { Object.assign(form, createMaterialForm(row, categories.value))
material_code: row.material_code,
name: row.name,
spec: row.spec || '',
category: row.category || '',
unit: row.unit,
warning_stock: row.warning_stock,
material_image: row.material_image || ''
})
imagePreview.value = '' imagePreview.value = ''
if (imageInput.value) imageInput.value.value = '' if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true dialogVisible.value = true
} }
async function handleSave() { async function handleSave() {
if (!form.major_category_id || !form.category_id) {
ElMessage.warning('请选择大类类型和小类类型')
return
}
saveLoading.value = true saveLoading.value = true
try { try {
const formData = { ...form } const formData = toMaterialPayload(form)
const file = imageInput.value?.files[0] const file = imageInput.value?.files[0]
if (file) { if (file) {
const uploadForm = new FormData() const uploadForm = new FormData()
@ -290,8 +276,17 @@ function handleDelete(id) {
async function confirmDelete() { async function confirmDelete() {
try { try {
await materialsStore.deleteMaterial(deleteId.value) await materialsStore.obsoleteMaterial(deleteId.value)
ElMessage.success('物料删除成功') ElMessage.success('物料已作废')
} catch (e) {
// 错误已由 API 层处理
}
}
async function handleRestore(id) {
try {
await materialsStore.restoreMaterial(id)
ElMessage.success('物料已恢复')
} catch (e) { } catch (e) {
// 错误已由 API 层处理 // 错误已由 API 层处理
} }
@ -338,4 +333,7 @@ function formatDate(dateStr) {
.search-native-input:focus { .search-native-input:focus {
border-color: #409eff; border-color: #409eff;
} }
:deep(.el-table__cell.operation-column) {
border-left: 1px solid var(--el-table-border-color) !important;
}
</style> </style>

View File

@ -23,12 +23,12 @@
<el-table-column prop="receiver" label="接收方" /> <el-table-column prop="receiver" label="接收方" />
<el-table-column prop="operator" label="操作人" /> <el-table-column prop="operator" label="操作人" />
<el-table-column prop="remark" label="备注" /> <el-table-column prop="remark" label="备注" />
<el-table-column prop="created_at" label="创建时间" width="160"> <el-table-column prop="created_at" label="创建时间" width="160" class-name="pr-3">
<template #default="{ row }"> <template #default="{ row }">
{{ formatDate(row.created_at) }} {{ formatDate(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right" class-name="border-left">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button> <el-button size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button> <el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
@ -115,6 +115,7 @@ import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { isLowStock as isQuantityLowStock } from '../utils/quantity'
const outboundStore = useOutboundStore() const outboundStore = useOutboundStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -227,8 +228,7 @@ function isLowStock(materialId) {
if (!materialId) return false if (!materialId) return false
const stock = stockStore.stockList.find(s => s.material_id === materialId) const stock = stockStore.stockList.find(s => s.material_id === materialId)
if (!stock) return false if (!stock) return false
const material = materialsStore.materials.find(m => m.id === materialId) return isQuantityLowStock(stock)
return material && stock.current_stock <= material.warning_stock
} }
async function handleSave() { async function handleSave() {
@ -269,8 +269,15 @@ async function confirmDelete() {
ElMessage.success('删除成功') ElMessage.success('删除成功')
} }
function showDetail(row) { async function showDetail(row) {
currentRow.value = row try {
const detail = await outboundStore.fetchOutboundDetail(row.id)
currentRow.value = detail
} catch (error) {
console.error('获取详情失败:', error)
ElMessage.error('获取详情失败')
currentRow.value = { ...row, items: [] }
}
detailVisible.value = true detailVisible.value = true
} }
@ -310,4 +317,10 @@ function formatDate(dateStr) {
.mt-3 { .mt-3 {
margin-top: 16px; margin-top: 16px;
} }
:deep(.pr-3) {
padding-right: 12px;
}
:deep(.border-left) {
border-left: 1px solid var(--el-table-border-color);
}
</style> </style>

View File

@ -1,5 +1,9 @@
<template> <template>
<div class="stock-page"> <div class="stock-page">
<div class="page-header">
<h2>库存查询</h2>
<el-button v-if="authStore.isAdmin" type="primary" plain @click="warehouseDialogVisible = true">仓库设置</el-button>
</div>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="库存查询" name="list"> <el-tab-pane label="库存查询" name="list">
<div class="search-bar"> <div class="search-bar">
@ -10,21 +14,7 @@
style="width: 220px" style="width: 220px"
/> />
<el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px"> <el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px">
<el-option label="标准件" value="A01" /> <el-option v-for="cat in majorCategories" :key="cat.id" :label="`${cat.code} ${cat.name}`" :value="cat.code" />
<el-option label="成品件" value="A02" />
<el-option label="简易加工件" value="A03" />
<el-option label="精密加工件" value="A04" />
<el-option label="钣金件" value="A05" />
<el-option label="塑胶件" value="A06" />
<el-option label="其他" value="A07" />
<el-option label="线束" value="B05" />
<el-option label="组装组件" value="B06" />
<el-option label="连接耗材" value="C01" />
<el-option label="自研程序固件" value="D01" />
<el-option label="外购软件授权" value="D02" />
<el-option label="成品" value="E01" />
<el-option label="资料文档" value="F01" />
<el-option label="随机工具配件" value="F04" />
</el-select> </el-select>
<el-button @click="handleSearch">查询</el-button> <el-button @click="handleSearch">查询</el-button>
</div> </div>
@ -36,25 +26,38 @@
:total="stockStore.stockPage.total" :total="stockStore.stockPage.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_id" label="物料ID" />
<el-table-column prop="material_code" label="物料编码" /> <el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="spec" label="规格" /> <el-table-column prop="spec" label="规格" />
<el-table-column label="分类"> <el-table-column label="大类类型">
<template #default="{ row }"> <template #default="{ row }">
{{ getCategoryDisplay(row.category) }} {{ getMajorCategoryDisplay(row.major_category) }}
</template>
</el-table-column>
<el-table-column label="小类类型">
<template #default="{ row }">
{{ getMinorCategoryDisplay(row.minor_category) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="unit" label="单位" /> <el-table-column prop="unit" label="单位" />
<el-table-column prop="current_stock" label="库存数量" /> <el-table-column prop="current_stock" label="库存数量" />
<el-table-column prop="warning_stock" label="预警值" /> <el-table-column prop="warning_stock" label="预警值" />
<el-table-column label="状态"> <el-table-column label="状态" :class-name="'pr-3'">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.current_stock <= row.warning_stock" type="danger">库存不足</el-tag> <el-tag v-if="isLowStock(row)" type="danger">库存不足</el-tag>
<el-tag v-else type="success">正常</el-tag> <el-tag v-else type="success">正常</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right" class-name="pl-3 border-left">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openDetailDialog(row)">
详情
</el-button>
<el-button size="small" type="primary" link @click="openWarningDialog(row)">
设置预警
</el-button>
</template>
</el-table-column>
</BaseTable> </BaseTable>
</el-tab-pane> </el-tab-pane>
@ -68,24 +71,23 @@
@page-change="handleWarningPageChange" @page-change="handleWarningPageChange"
> >
<el-table-column type="index" label="ID" width="60" /> <el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_id" label="物料ID" /> <el-table-column prop="name" label="物料名称" />
<el-table-column prop="material_name" label="物料名称" />
<el-table-column prop="material_code" label="物料编码" /> <el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="quantity" label="当前库存"> <el-table-column prop="current_stock" label="当前库存">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: red">{{ row.quantity }}</span> <span style="color: red">{{ row.current_stock }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="warning_stock" label="预警值" /> <el-table-column prop="warning_stock" label="预警值" />
<el-table-column label="缺货量"> <el-table-column label="缺货量">
<template #default="{ row }"> <template #default="{ row }">
{{ Math.max(0, row.warning_stock - row.quantity) }} {{ Math.max(0, row.warning_stock - row.current_stock) }}
</template> </template>
</el-table-column> </el-table-column>
</BaseTable> </BaseTable>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="库存日志" name="logs"> <el-tab-pane label="库存流水" name="logs">
<div class="logs-filter"> <div class="logs-filter">
<el-select <el-select
v-model="logMaterialId" v-model="logMaterialId"
@ -139,21 +141,79 @@
</BaseTable> </BaseTable>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-dialog v-model="warningDialogVisible" title="设置库存预警" width="420px">
<el-form label-width="100px">
<el-form-item label="物料">
<span>{{ warningMaterial?.material_code }} - {{ warningMaterial?.name }}</span>
</el-form-item>
<el-form-item label="预警阈值">
<el-input-number v-model="warningValue" :min="0" :step="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="warningDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="warningSaving" @click="saveWarning">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="warehouseDialogVisible" title="仓库设置" width="760px">
<WarehouseSettings />
</el-dialog>
<el-dialog v-model="detailDialogVisible" title="库存详情" width="910px">
<div v-if="detailMaterial" class="detail-header">
<span>物料ID:{{ detailMaterial.material_id }}</span>
<span>编码:{{ detailMaterial.material_code }}</span>
<span>{{ detailMaterial.name }}</span>
<span>{{ detailMaterial.spec }}</span>
<span>库存数量:{{ detailMaterial.current_stock }}</span>
</div>
<el-table :data="balanceDetails" stripe class="mt-3">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="organization" label="组织" />
<el-table-column prop="warehouse" label="仓库" />
<el-table-column prop="location" label="库位" />
<el-table-column prop="on_hand" label="库存数量" />
<el-table-column prop="reserved" label="预留" />
<el-table-column prop="locked" label="锁定" />
<el-table-column prop="in_transit" label="在途" />
<el-table-column prop="updated_at" label="更新时间">
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useStockStore } from '../stores/stock' import { useStockStore } from '../stores/stock'
import { stockApi } from '../api/stock'
import { useMaterialsStore } from '../stores/materials' import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage } from 'element-plus'
import WarehouseSettings from './inventory/WarehouseSettings.vue'
import { isLowStock } from '../utils/quantity'
import { materialCategoriesApi } from '../api/masterData'
import { getMajorCategories } from '../utils/materialCategories'
const stockStore = useStockStore() const stockStore = useStockStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
const authStore = useAuthStore()
const activeTab = ref('list') const activeTab = ref('list')
const warehouseDialogVisible = ref(false)
const logMaterialId = ref('') const logMaterialId = ref('')
const detailDialogVisible = ref(false)
const detailMaterial = ref(null)
const balanceDetails = ref([])
const categories = ref([])
const majorCategories = computed(() => getMajorCategories(categories.value))
// Watch tab changes to load logs when switching to logs tab // Watch tab changes to load logs when switching to logs tab
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
@ -164,30 +224,31 @@ watch(activeTab, (newTab) => {
const logMaterialSearchText = ref('') const logMaterialSearchText = ref('')
const searchKeyword = ref('') const searchKeyword = ref('')
const categoryFilter = ref('') const categoryFilter = ref('')
const warningDialogVisible = ref(false)
const warningSaving = ref(false)
const warningMaterial = ref(null)
const warningValue = ref(0)
const categoryMap = { onMounted(async () => {
'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件',
'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他',
'B01': '成品件', 'B02': '简易加工件', 'B03': 'PCB硬板', 'B04': 'FPC软板',
'B05': '线束', 'B06': '组装组件', 'B07': '其他',
'C01': '连接耗材', 'C02': '密封耗材', 'C03': '其他',
'D01': '自研程序固件', 'D02': '外购软件授权', 'D03': '授权介质', 'D04': '其他',
'E01': '成品', 'E02': '半成品',
'F01': '资料文档', 'F02': '标识标牌', 'F03': '包装物料', 'F04': '随机工具配件', 'F05': '其他'
}
function getCategoryDisplay(category) {
if (!category) return '-'
const name = categoryMap[category] || ''
return name ? `${category} - ${name}` : category
}
onMounted(() => {
stockStore.fetchStock() stockStore.fetchStock()
stockStore.fetchStockWarning() stockStore.fetchStockWarning()
materialsStore.fetchMaterials() materialsStore.fetchMaterials()
const catRes = await materialCategoriesApi.list()
categories.value = catRes.data || []
}) })
function getMajorCategoryDisplay(majorCode) {
if (!majorCode) return '-'
const major = categories.value.find(c => c.code === majorCode && !c.parent_id)
return major ? `${major.code} ${major.name}` : majorCode || '-'
}
function getMinorCategoryDisplay(minorCode) {
if (!minorCode) return '-'
const minor = categories.value.find(c => c.code === minorCode)
return minor ? minor.name : minorCode || '-'
}
function handleSearch() { function handleSearch() {
stockStore.fetchStock(1, searchKeyword.value, categoryFilter.value) stockStore.fetchStock(1, searchKeyword.value, categoryFilter.value)
} }
@ -196,6 +257,37 @@ function handlePageChange(page) {
stockStore.fetchStock(page, searchKeyword.value, categoryFilter.value) stockStore.fetchStock(page, searchKeyword.value, categoryFilter.value)
} }
function openWarningDialog(row) {
warningMaterial.value = row
warningValue.value = row.warning_stock || 0
}
async function openDetailDialog(row) {
detailMaterial.value = row
detailDialogVisible.value = true
try {
const res = await stockApi.getInventoryBalanceDetails(row.material_id)
balanceDetails.value = res.data || []
} catch (error) {
ElMessage.error('获取库存详情失败')
balanceDetails.value = []
}
}
async function saveWarning() {
if (!warningMaterial.value) return
warningSaving.value = true
try {
await stockStore.updateWarningStock(warningMaterial.value.material_id, warningValue.value)
warningDialogVisible.value = false
ElMessage.success('库存预警已更新')
} catch (error) {
ElMessage.error(error.message || '库存预警更新失败')
} finally {
warningSaving.value = false
}
}
function logMaterialFilter(val) { function logMaterialFilter(val) {
logMaterialSearchText.value = val logMaterialSearchText.value = val
} }
@ -241,6 +333,15 @@ function getReferenceTypeName(type) {
.stock-page { .stock-page {
padding: 20px; padding: 20px;
} }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-header h2 {
margin: 0;
}
.logs-filter { .logs-filter {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -251,4 +352,21 @@ function getReferenceTypeName(type) {
gap: 10px; gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
} }
:deep(.border-left) {
border-left: 1px solid var(--el-table-border-color);
}
.detail-header {
display: flex;
gap: 20px;
padding: 10px 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.detail-header span:not(:first-child)::before {
content: '|';
margin-right: 20px;
}
.mt-3 {
margin-top: 12px;
}
</style> </style>

View File

@ -25,9 +25,17 @@
> >
<el-table-column type="index" label="ID" width="60" /> <el-table-column type="index" label="ID" width="60" />
<el-table-column prop="username" label="用户名" /> <el-table-column prop="username" label="用户名" />
<el-table-column prop="role" label="角色"> <el-table-column label="角色" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<RoleBadge :role="row.role" /> <el-tag
v-for="role in row.roles || []"
:key="role.id"
:type="role.code === 'super_admin' ? 'danger' : 'info'"
class="role-tag"
>
{{ role.name }}
</el-tag>
<span v-if="!row.roles || row.roles.length === 0">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="描述" /> <el-table-column prop="description" label="描述" />
@ -44,7 +52,7 @@
</el-table-column> </el-table-column>
</BaseTable> </BaseTable>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px">
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="80px">
<el-form-item label="用户名"> <el-form-item label="用户名">
<el-input v-model="form.username" :readonly="!!editingId" /> <el-input v-model="form.username" :readonly="!!editingId" />
@ -53,13 +61,18 @@
<el-input v-model="form.password" type="password" /> <el-input v-model="form.password" type="password" />
</el-form-item> </el-form-item>
<el-form-item label="角色"> <el-form-item label="角色">
<el-select v-model="form.role" style="width: 100%"> <el-select v-model="form.role_ids" multiple style="width: 100%" placeholder="请选择至少一个角色">
<el-option label="管理员" value="admin" /> <el-option
<el-option label="普通用户" value="user" /> v-for="role in roles"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="!role.enabled"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" rows="3" /> <el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -76,8 +89,8 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useUsersStore } from '../stores/users' import { useUsersStore } from '../stores/users'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { organizationsApi } from '../api/organizations'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import RoleBadge from '../components/business/RoleBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@ -91,11 +104,12 @@ const saveLoading = ref(false)
const editingId = ref(null) const editingId = ref(null)
const deleteId = ref(null) const deleteId = ref(null)
const searchKeyword = ref('') const searchKeyword = ref('')
const roles = ref([])
const form = reactive({ const form = reactive({
username: '', username: '',
password: '', password: '',
role: 'user', role_ids: [],
description: '' description: ''
}) })
@ -104,9 +118,14 @@ onMounted(() => {
ElMessage.warning('无权限访问') ElMessage.warning('无权限访问')
return return
} }
usersStore.fetchUsers() Promise.all([usersStore.fetchUsers(), loadRoles()])
}) })
async function loadRoles() {
const response = await organizationsApi.listRoles()
roles.value = response.data || []
}
function handleSearch() { function handleSearch() {
usersStore.fetchUsers(1, searchKeyword.value) usersStore.fetchUsers(1, searchKeyword.value)
} }
@ -122,7 +141,7 @@ function showAddModal() {
} }
editingId.value = null editingId.value = null
dialogTitle.value = '新增用户' dialogTitle.value = '新增用户'
Object.assign(form, { username: '', password: '', role: 'user', description: '' }) Object.assign(form, { username: '', password: '', role_ids: [], description: '' })
dialogVisible.value = true dialogVisible.value = true
} }
@ -136,7 +155,7 @@ function showEditModal(row) {
Object.assign(form, { Object.assign(form, {
username: row.username, username: row.username,
password: '', password: '',
role: row.role, role_ids: (row.roles || []).map(role => role.id),
description: row.description || '' description: row.description || ''
}) })
dialogVisible.value = true dialogVisible.value = true
@ -151,6 +170,10 @@ async function handleSave() {
ElMessage.warning('请输入密码') ElMessage.warning('请输入密码')
return return
} }
if (form.role_ids.length === 0) {
ElMessage.warning('请至少选择一个角色')
return
}
saveLoading.value = true saveLoading.value = true
try { try {
const data = { ...form } const data = { ...form }
@ -202,4 +225,7 @@ function formatDate(dateStr) {
gap: 10px; gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
} }
.role-tag {
margin-right: 6px;
}
</style> </style>

View File

@ -3,24 +3,17 @@ const materialKeys = [
'name', 'name',
'spec', 'spec',
'category', 'category',
'category_id',
'unit', 'unit',
'base_unit_id',
'material_image' 'material_image'
] ]
export function createMaterialForm(source = {}, categories = []) { export function createMaterialForm(source = {}) {
const selectedCategory = categories.find(item => item.id === source.category_id)
const majorCategoryId = selectedCategory?.parent_id || source.major_category_id || null
return { return {
material_code: source.material_code || '', material_code: source.material_code || '',
name: source.name || '', name: source.name || '',
spec: source.spec || '', spec: source.spec || '',
major_category_id: majorCategoryId,
category: source.category || '', category: source.category || '',
category_id: source.category_id || null,
unit: source.unit || 'pcs', unit: source.unit || 'pcs',
base_unit_id: source.base_unit_id || null,
material_image: source.material_image || '' material_image: source.material_image || ''
} }
} }
@ -28,8 +21,6 @@ export function createMaterialForm(source = {}, categories = []) {
export function toMaterialPayload(source) { export function toMaterialPayload(source) {
const payload = {} const payload = {}
for (const key of materialKeys) payload[key] = source[key] || '' 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' if (!payload.unit) payload.unit = 'pcs'
return payload return payload
} }

View File

@ -7,11 +7,8 @@ describe('material master-data form', () => {
material_code: '', material_code: '',
name: '', name: '',
spec: '', spec: '',
major_category_id: null,
category: '', category: '',
category_id: null,
unit: 'pcs', unit: 'pcs',
base_unit_id: null,
material_image: '' material_image: ''
}) })
}) })
@ -21,11 +18,8 @@ describe('material master-data form', () => {
material_code: 'MAT-001', material_code: 'MAT-001',
name: '轴承', name: '轴承',
spec: '6205', spec: '6205',
major_category_id: 10,
category: 'A02', category: 'A02',
category_id: 12,
unit: '件', unit: '件',
base_unit_id: 3,
material_image: '', material_image: '',
current_stock: 99, current_stock: 99,
warning_stock: 10 warning_stock: 10
@ -34,23 +28,8 @@ describe('material master-data form', () => {
name: '轴承', name: '轴承',
spec: '6205', spec: '6205',
category: 'A02', category: 'A02',
category_id: 12,
unit: '件', unit: '件',
base_unit_id: 3,
material_image: '' material_image: ''
}) })
}) })
it('restores the major category when editing a material assigned to a minor category', () => {
const categories = [
{ id: 10, code: 'A02', name: '成品件', parent_id: null },
{ id: 12, code: 'A02-G', name: '成品件·通用型', parent_id: 10 }
]
expect(createMaterialForm({ category_id: 12, category: 'A02-G' }, categories)).toMatchObject({
major_category_id: 10,
category_id: 12,
category: 'A02-G'
})
})
}) })

View File

@ -4,33 +4,44 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/middleware" "github.com/wmms/wmms/internal/middleware"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/internal/service" "github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response" "github.com/wmms/wmms/pkg/response"
) )
type Handler struct { type Handler struct {
materialSvc *service.MaterialService materialSvc *service.MaterialService
inboundSvc *service.InboundService inboundSvc *service.InboundService
outboundSvc *service.OutboundService outboundSvc *service.OutboundService
stockSvc *service.StockService stockSvc *service.StockService
logSvc *service.MaterialLogService logSvc *service.MaterialLogService
authSvc *service.AuthService authSvc *service.AuthService
appSvc *service.ApplicationService appSvc *service.ApplicationService
organizationSvc *service.OrganizationService
masterDataSvc *service.MasterDataService
partnerSvc *service.PartnerService
warehouseSvc *service.WarehouseService
} }
func NewHandler() *Handler { func NewHandler() *Handler {
return &Handler{ return &Handler{
materialSvc: service.NewMaterialService(), materialSvc: service.NewMaterialService(),
inboundSvc: service.NewInboundService(), inboundSvc: service.NewInboundService(),
outboundSvc: service.NewOutboundService(), outboundSvc: service.NewOutboundService(),
stockSvc: service.NewStockService(), stockSvc: service.NewStockService(),
logSvc: service.NewMaterialLogService(), logSvc: service.NewMaterialLogService(),
authSvc: service.NewAuthService(), authSvc: service.NewAuthService(),
appSvc: service.NewApplicationService(), appSvc: service.NewApplicationService(),
organizationSvc: service.NewOrganizationService(),
masterDataSvc: service.NewMasterDataService(),
partnerSvc: service.NewPartnerService(),
warehouseSvc: service.NewWarehouseService(),
} }
} }
@ -66,7 +77,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
materials.GET("/:id", h.GetMaterial) materials.GET("/:id", h.GetMaterial)
materials.POST("", h.CreateMaterial) materials.POST("", h.CreateMaterial)
materials.PUT("/:id", h.UpdateMaterial) materials.PUT("/:id", h.UpdateMaterial)
materials.DELETE("/:id", h.DeleteMaterial) materials.POST("/:id/obsolete", h.ObsoleteMaterial)
} }
inbound := api.Group("/inbound") inbound := api.Group("/inbound")
@ -98,6 +109,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
stock.GET("/warning", h.GetWarningStock) stock.GET("/warning", h.GetWarningStock)
stock.GET("/logs", h.GetAllStockLogs) stock.GET("/logs", h.GetAllStockLogs)
stock.GET("/logs/:materialId", h.GetStockLogs) stock.GET("/logs/:materialId", h.GetStockLogs)
stock.PUT("/:materialId/warning", h.UpdateWarningStock)
stock.GET("/:materialId", h.GetStock) stock.GET("/:materialId", h.GetStock)
} }
@ -111,6 +123,69 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
users.DELETE("/:id", h.DeleteUser) users.DELETE("/:id", h.DeleteUser)
} }
organizations := api.Group("/organizations")
organizations.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
organizations.GET("", h.ListOrganizations)
organizations.POST("", h.CreateOrganization)
organizations.PUT("/:id", h.UpdateOrganization)
}
roles := api.Group("/roles")
roles.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
roles.GET("", h.ListRoles)
roles.PUT("/:id", h.UpdateRole)
}
permissions := api.Group("/permissions")
permissions.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
permissions.GET("", h.ListPermissions)
}
categories := api.Group("/material-categories")
categories.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
categories.GET("", h.ListMaterialCategories)
categories.POST("", h.CreateMaterialCategory)
categories.PUT("/:id", h.UpdateMaterialCategory)
}
units := api.Group("/units")
units.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
units.GET("", h.ListUnits)
units.POST("", h.CreateUnit)
units.PUT("/:id", h.UpdateUnit)
}
codeRules := api.Group("/code-rules")
codeRules.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
codeRules.GET("", h.ListCodeRules)
codeRules.POST("", h.CreateCodeRule)
codeRules.PUT("/:id", h.UpdateCodeRule)
}
partners := api.Group("/partners")
partners.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
partners.GET("", h.ListPartners)
partners.POST("", h.CreatePartner)
partners.PUT("/:id", h.UpdatePartner)
}
warehouses := api.Group("/warehouses")
warehouses.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
warehouses.GET("", h.ListWarehouses)
warehouses.POST("", h.CreateWarehouse)
warehouses.PUT("/:id", h.UpdateWarehouse)
warehouses.POST("/:id/locations", h.CreateWarehouseLocation)
warehouses.PUT("/:id/locations/:locationId", h.UpdateWarehouseLocation)
}
// Application routes (user: create/cancel own, list own; admin: list all, approve/reject) // Application routes (user: create/cancel own, list own; admin: list all, approve/reject)
applications := api.Group("/applications") applications := api.Group("/applications")
applications.Use(middleware.AuthMiddleware()) applications.Use(middleware.AuthMiddleware())
@ -118,9 +193,9 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
applications.POST("", h.CreateApplication) applications.POST("", h.CreateApplication)
applications.GET("/my", h.ListMyApplications) applications.GET("/my", h.ListMyApplications)
applications.POST("/:id/cancel", h.CancelApplication) applications.POST("/:id/cancel", h.CancelApplication)
applications.GET("", h.ListApplications) // admin only applications.GET("", h.ListApplications) // admin only
applications.POST("/:id/approve", h.ApproveApplication) // admin only applications.POST("/:id/approve", h.ApproveApplication) // admin only
applications.POST("/:id/reject", h.RejectApplication) // admin only applications.POST("/:id/reject", h.RejectApplication) // admin only
} }
// Current user route // Current user route
@ -191,6 +266,11 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
orgID, ok := organizationID(c)
if !ok {
return
}
req.OrganizationID = orgID
operator := middleware.GetCurrentUsername(c) operator := middleware.GetCurrentUsername(c)
if operator == "" { if operator == "" {
@ -259,6 +339,27 @@ func (h *Handler) DeleteMaterial(c *gin.Context) {
response.SuccessWithMessage(c, "Material deleted successfully", nil) response.SuccessWithMessage(c, "Material deleted successfully", nil)
} }
func (h *Handler) ObsoleteMaterial(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "invalid material id")
return
}
operator := middleware.GetCurrentUsername(c)
if operator == "" {
operator = "系统"
}
if err := h.materialSvc.Obsolete(id, operator); err != nil {
if strings.Contains(err.Error(), "仍有库存") {
response.Error(c, 409, err.Error())
return
}
response.InternalError(c, err.Error())
return
}
response.SuccessWithMessage(c, "Material obsoleted successfully", nil)
}
// Inbound handlers // Inbound handlers
func (h *Handler) ListInbound(c *gin.Context) { func (h *Handler) ListInbound(c *gin.Context) {
@ -549,9 +650,8 @@ func (h *Handler) ListStock(c *gin.Context) {
} }
search := c.Query("search") search := c.Query("search")
category := c.Query("category")
stocks, total, err := h.stockSvc.GetAllStock(search, category, limit, offset) stocks, total, err := h.stockSvc.GetAllStock(search, limit, offset)
if err != nil { if err != nil {
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
@ -609,6 +709,30 @@ func (h *Handler) GetWarningStock(c *gin.Context) {
response.PaginatedSuccess(c, stocks, limit, offset, total) response.PaginatedSuccess(c, stocks, limit, offset, total)
} }
func (h *Handler) UpdateWarningStock(c *gin.Context) {
materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64)
if err != nil {
response.BadRequest(c, "invalid material id")
return
}
var req struct {
WarningStock decimal.Decimal `json:"warning_stock"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if req.WarningStock.IsNegative() {
response.BadRequest(c, "warning_stock must be greater than or equal to 0")
return
}
if err := h.stockSvc.UpdateWarningStock(materialID, req.WarningStock); err != nil {
response.InternalError(c, err.Error())
return
}
response.SuccessWithMessage(c, "Stock warning updated successfully", nil)
}
func (h *Handler) GetStockLogs(c *gin.Context) { func (h *Handler) GetStockLogs(c *gin.Context) {
materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64) materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64)
if err != nil { if err != nil {
@ -771,8 +895,6 @@ func (h *Handler) Logout(c *gin.Context) {
func (h *Handler) GetCurrentUser(c *gin.Context) { func (h *Handler) GetCurrentUser(c *gin.Context) {
userID := middleware.GetCurrentUserID(c) userID := middleware.GetCurrentUserID(c)
username := middleware.GetCurrentUsername(c)
role := middleware.GetCurrentRole(c)
user, err := h.authSvc.GetUserByID(userID) user, err := h.authSvc.GetUserByID(userID)
if err != nil { if err != nil {
@ -780,12 +902,7 @@ func (h *Handler) GetCurrentUser(c *gin.Context) {
return return
} }
// Return user info (password already hidden with json:"-" tag) response.Success(c, user)
response.Success(c, gin.H{
"id": user.ID,
"username": username,
"role": role,
})
} }
// User management handlers (Admin only) // User management handlers (Admin only)
@ -821,10 +938,10 @@ func (h *Handler) ListUsers(c *gin.Context) {
func (h *Handler) CreateUser(c *gin.Context) { func (h *Handler) CreateUser(c *gin.Context) {
var req struct { var req struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
Role string `json:"role" binding:"required"` RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
Description string `json:"description"` Description string `json:"description"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -832,13 +949,12 @@ func (h *Handler) CreateUser(c *gin.Context) {
return return
} }
if req.Role != "admin" && req.Role != "user" { user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs)
response.BadRequest(c, "invalid role, must be admin or user")
return
}
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Role, req.Description)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) {
response.BadRequest(c, err.Error())
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -854,10 +970,9 @@ func (h *Handler) UpdateUser(c *gin.Context) {
} }
var req struct { var req struct {
Username string `json:"username"` Password string `json:"password"`
Password string `json:"password"` RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
Role string `json:"role"` Description string `json:"description"`
Description string `json:"description"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -865,13 +980,12 @@ func (h *Handler) UpdateUser(c *gin.Context) {
return return
} }
if req.Role != "admin" && req.Role != "user" { err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password)
response.BadRequest(c, "invalid role, must be admin or user")
return
}
err = h.authSvc.UpdateUser(id, req.Username, req.Role, req.Description, req.Password)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) || errors.Is(err, repository.ErrLastSuperAdmin) {
response.BadRequest(c, err.Error())
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -894,6 +1008,10 @@ func (h *Handler) DeleteUser(c *gin.Context) {
} }
if err := h.authSvc.DeleteUser(id); err != nil { if err := h.authSvc.DeleteUser(id); err != nil {
if errors.Is(err, repository.ErrLastSuperAdmin) {
response.BadRequest(c, err.Error())
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
@ -950,9 +1068,7 @@ func (h *Handler) ListMyApplications(c *gin.Context) {
} }
func (h *Handler) ListApplications(c *gin.Context) { func (h *Handler) ListApplications(c *gin.Context) {
// Check if admin if !middleware.IsSuperAdmin(c) {
role := middleware.GetCurrentRole(c)
if role != "admin" {
response.Unauthorized(c, "admin only") response.Unauthorized(c, "admin only")
return return
} }
@ -1010,9 +1126,7 @@ func (h *Handler) ApproveApplication(c *gin.Context) {
return return
} }
// Check if admin if !middleware.IsSuperAdmin(c) {
role := middleware.GetCurrentRole(c)
if role != "admin" {
response.Unauthorized(c, "admin only") response.Unauthorized(c, "admin only")
return return
} }
@ -1048,9 +1162,7 @@ func (h *Handler) RejectApplication(c *gin.Context) {
return return
} }
// Check if admin if !middleware.IsSuperAdmin(c) {
role := middleware.GetCurrentRole(c)
if role != "admin" {
response.Unauthorized(c, "admin only") response.Unauthorized(c, "admin only")
return return
} }

View File

@ -10,10 +10,10 @@ import (
const ( const (
AuthorizationHeader = "Authorization" AuthorizationHeader = "Authorization"
BearerPrefix = "Bearer " BearerPrefix = "Bearer "
UserIDKey = "user_id" UserIDKey = "user_id"
UsernameKey = "username" UsernameKey = "username"
RoleKey = "role" RolesKey = "roles"
) )
// AuthMiddleware validates JWT tokens // AuthMiddleware validates JWT tokens
@ -46,7 +46,7 @@ func AuthMiddleware() gin.HandlerFunc {
// Set user info in context // Set user info in context
c.Set(UserIDKey, token.UserID) c.Set(UserIDKey, token.UserID)
c.Set(UsernameKey, token.Username) c.Set(UsernameKey, token.Username)
c.Set(RoleKey, token.Role) c.Set(RolesKey, token.Roles)
c.Next() c.Next()
} }
@ -55,14 +55,14 @@ func AuthMiddleware() gin.HandlerFunc {
// AdminOnly middleware restricts access to admin role only // AdminOnly middleware restricts access to admin role only
func AdminOnly() gin.HandlerFunc { func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get(RoleKey) roles, exists := c.Get(RolesKey)
if !exists { if !exists {
response.Unauthorized(c, "unauthorized") response.Unauthorized(c, "unauthorized")
c.Abort() c.Abort()
return return
} }
if role != "admin" { if !hasRole(roles, "super_admin") {
response.Forbidden(c, "admin access required") response.Forbidden(c, "admin access required")
c.Abort() c.Abort()
return return
@ -88,12 +88,32 @@ func GetCurrentUsername(c *gin.Context) string {
return "" return ""
} }
// GetCurrentRole returns the current user's role from context // GetCurrentRoles returns the authenticated role-code snapshot. This is the
func GetCurrentRole(c *gin.Context) string { // extension point for replacing token roles with a request-time principal.
if role, exists := c.Get(RoleKey); exists { func GetCurrentRoles(c *gin.Context) []string {
return role.(string) if roles, exists := c.Get(RolesKey); exists {
if values, ok := roles.([]string); ok {
return values
}
} }
return "" return []string{}
}
func IsSuperAdmin(c *gin.Context) bool {
return hasRole(GetCurrentRoles(c), "super_admin")
}
func hasRole(value interface{}, expected string) bool {
roles, ok := value.([]string)
if !ok {
return false
}
for _, role := range roles {
if role == expected {
return true
}
}
return false
} }
// OptionalAuth middleware extracts user info if token is present but doesn't require it // OptionalAuth middleware extracts user info if token is present but doesn't require it
@ -122,7 +142,7 @@ func OptionalAuth() gin.HandlerFunc {
c.Set(UserIDKey, token.UserID) c.Set(UserIDKey, token.UserID)
c.Set(UsernameKey, token.Username) c.Set(UsernameKey, token.Username)
c.Set(RoleKey, token.Role) c.Set(RolesKey, token.Roles)
c.Next() c.Next()
} }

View File

@ -1,20 +1,27 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
// Material represents a material in the warehouse // Material represents a material in the warehouse
type Material struct { type Material struct {
ID int64 `json:"id"` ID int64 `json:"id"`
MaterialCode string `json:"material_code"` OrganizationID int64 `json:"organization_id"`
Name string `json:"name"` MaterialCode string `json:"material_code"`
MaterialImage *string `json:"material_image,omitempty"` Name string `json:"name"`
Spec string `json:"spec,omitempty"` MaterialImage *string `json:"material_image,omitempty"`
Category string `json:"category,omitempty"` Spec string `json:"spec,omitempty"`
Unit string `json:"unit,omitempty"` ManufacturerModel *string `json:"manufacturer_model,omitempty"`
WarningStock int `json:"warning_stock"` CategoryID *int64 `json:"category_id,omitempty"`
CurrentStock int `json:"current_stock,omitempty"` BaseUnitID *int64 `json:"base_unit_id,omitempty"`
CreatedAt time.Time `json:"created_at"` Unit string `json:"unit,omitempty"`
UpdatedAt time.Time `json:"updated_at"` Status string `json:"status"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// Tag represents a tag associated with a material // Tag represents a tag associated with a material
@ -27,33 +34,33 @@ type Tag struct {
// ApplyOrder represents a material application/requisition order // ApplyOrder represents a material application/requisition order
type ApplyOrder struct { type ApplyOrder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Applicant string `json:"applicant"` Applicant string `json:"applicant"`
ApplyDate time.Time `json:"apply_date"` ApplyDate time.Time `json:"apply_date"`
Status string `json:"status"` Status string `json:"status"`
ApprovalComment string `json:"approval_comment,omitempty"` ApprovalComment string `json:"approval_comment,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// ApplyItem represents an item in an application order // ApplyItem represents an item in an application order
type ApplyItem struct { type ApplyItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ApplyOrderID int64 `json:"apply_order_id"` ApplyOrderID int64 `json:"apply_order_id"`
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
Purpose string `json:"purpose,omitempty"` Purpose string `json:"purpose,omitempty"`
} }
// InboundOrder represents an inbound order // InboundOrder represents an inbound order
type InboundOrder struct { type InboundOrder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
InboundDate time.Time `json:"inbound_date"` InboundDate time.Time `json:"inbound_date"`
Handler string `json:"handler"` Handler string `json:"handler"`
Supplier string `json:"supplier"` Supplier string `json:"supplier"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Items []InboundItem `json:"items,omitempty"` Items []InboundItem `json:"items,omitempty"`
} }
@ -69,13 +76,13 @@ type InboundItem struct {
// OutboundOrder represents an outbound order // OutboundOrder represents an outbound order
type OutboundOrder struct { type OutboundOrder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OutboundDate time.Time `json:"outbound_date"` OutboundDate time.Time `json:"outbound_date"`
Handler string `json:"handler"` Handler string `json:"handler"`
Receiver string `json:"receiver"` Receiver string `json:"receiver"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Items []OutboundItem `json:"items,omitempty"` Items []OutboundItem `json:"items,omitempty"`
} }
@ -91,83 +98,107 @@ type OutboundItem struct {
// StockLog represents a stock change log // StockLog represents a stock change log
type StockLog struct { type StockLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
Type string `json:"type"` Type string `json:"type"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
RelatedOrderType string `json:"related_order_type"` RelatedOrderType string `json:"related_order_type"`
RelatedOrderID int64 `json:"related_order_id"` RelatedOrderID int64 `json:"related_order_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
RunningBalance int `json:"running_balance,omitempty"` RunningBalance decimal.Decimal `json:"running_balance,omitempty"`
} }
// Stock represents stock information with material details // Stock represents stock information with material details
type Stock struct { type Stock struct {
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
MaterialCode string `json:"material_code"` MaterialCode string `json:"material_code"`
Name string `json:"name"` Name string `json:"name"`
Spec string `json:"spec,omitempty"` Spec string `json:"spec,omitempty"`
Category string `json:"category,omitempty"` Unit string `json:"unit,omitempty"`
Unit string `json:"unit,omitempty"` MajorCategory string `json:"major_category"`
CurrentStock int `json:"current_stock"` MinorCategory string `json:"minor_category"`
WarningStock int `json:"warning_stock"` CurrentStock decimal.Decimal `json:"current_stock"`
WarningStock decimal.Decimal `json:"warning_stock"`
}
// InventoryBalanceDetail represents detailed inventory balance
type InventoryBalanceDetail struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
Organization string `json:"organization"`
WarehouseID int64 `json:"warehouse_id"`
Warehouse string `json:"warehouse"`
LocationID int64 `json:"location_id"`
Location string `json:"location"`
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"`
UpdatedAt string `json:"updated_at"`
} }
// StockLogResponse represents a stock log with computed balance // StockLogResponse represents a stock log with computed balance
type StockLogResponse struct { type StockLogResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
ChangeType string `json:"change_type"` ChangeType string `json:"change_type"`
ChangeQuantity int `json:"change_quantity"` ChangeQuantity decimal.Decimal `json:"change_quantity"`
BeforeQuantity int `json:"before_quantity"` BeforeQuantity decimal.Decimal `json:"before_quantity"`
AfterQuantity int `json:"after_quantity"` AfterQuantity decimal.Decimal `json:"after_quantity"`
ReferenceID int64 `json:"reference_id"` ReferenceID int64 `json:"reference_id"`
ReferenceType string `json:"reference_type"` ReferenceType string `json:"reference_type"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// InboundRecord represents inbound records for a specific material // InboundRecord represents inbound records for a specific material
type InboundRecord struct { type InboundRecord struct {
ID int64 `json:"id"` ID int64 `json:"id"`
InboundID int64 `json:"inbound_id"` InboundID int64 `json:"inbound_id"`
Handler string `json:"handler"` Handler string `json:"handler"`
Supplier string `json:"supplier"` Supplier string `json:"supplier"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// OutboundRecord represents outbound records for a specific material // OutboundRecord represents outbound records for a specific material
type OutboundRecord struct { type OutboundRecord struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OutboundID int64 `json:"outbound_id"` OutboundID int64 `json:"outbound_id"`
Handler string `json:"handler"` Handler string `json:"handler"`
Receiver string `json:"receiver"` Receiver string `json:"receiver"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// ============= Request DTOs ============= // ============= Request DTOs =============
// CreateMaterialRequest represents the request to create a material // CreateMaterialRequest represents the request to create a material
type CreateMaterialRequest struct { type CreateMaterialRequest struct {
MaterialCode string `json:"material_code" binding:"required"` OrganizationID int64 `json:"organization_id"`
Name string `json:"name" binding:"required"` MaterialCode string `json:"material_code" binding:"required"`
MaterialImage string `json:"material_image"` Name string `json:"name" binding:"required"`
Spec string `json:"spec"` MaterialImage *string `json:"material_image"`
Category string `json:"category"` Spec string `json:"spec"`
Unit string `json:"unit"` ManufacturerModel *string `json:"manufacturer_model"`
WarningStock int `json:"warning_stock"` CategoryID *int64 `json:"category_id"`
BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
WarningStock decimal.Decimal `json:"warning_stock"`
} }
// UpdateMaterialRequest represents the request to update a material // UpdateMaterialRequest represents the request to update a material
type UpdateMaterialRequest struct { type UpdateMaterialRequest struct {
MaterialCode string `json:"material_code"` MaterialCode string `json:"material_code"`
Name string `json:"name"` Name string `json:"name"`
MaterialImage string `json:"material_image"` MaterialImage *string `json:"material_image"`
Spec string `json:"spec"` Spec string `json:"spec"`
Category string `json:"category"` ManufacturerModel *string `json:"manufacturer_model"`
Unit string `json:"unit"` CategoryID *int64 `json:"category_id"`
WarningStock int `json:"warning_stock"` BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
Status string `json:"status"`
WarningStock decimal.Decimal `json:"warning_stock"`
} }
// CreateInboundRequest represents the request to create an inbound order // CreateInboundRequest represents the request to create an inbound order
@ -228,6 +259,7 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"-"` Password string `json:"-"`
Role string `json:"role"` Role string `json:"role"`
Roles []Role `json:"roles"`
Description string `json:"description"` Description string `json:"description"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@ -254,16 +286,16 @@ const (
// MaterialApplication represents a material application record // MaterialApplication represents a material application record
type MaterialApplication struct { type MaterialApplication struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Purpose string `json:"purpose,omitempty"` Purpose string `json:"purpose,omitempty"`
Status string `json:"status"` Status string `json:"status"`
RejectionReason string `json:"rejection_reason,omitempty"` RejectionReason string `json:"rejection_reason,omitempty"`
ApprovedBy int64 `json:"approved_by,omitempty"` ApprovedBy int64 `json:"approved_by,omitempty"`
ApproverName string `json:"approver_name,omitempty"` ApproverName string `json:"approver_name,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Items []ApplicationItem `json:"items,omitempty"` Items []ApplicationItem `json:"items,omitempty"`
} }
@ -281,9 +313,9 @@ type ApplicationItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ApplicationID int64 `json:"application_id"` ApplicationID int64 `json:"application_id"`
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
MaterialCode string `json:"material_code"` MaterialCode string `json:"material_code"`
MaterialName string `json:"material_name"` MaterialName string `json:"material_name"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
} }
// ApproveApplicationRequest represents request to approve application // ApproveApplicationRequest represents request to approve application

View File

@ -194,32 +194,6 @@ func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApp
return r.GetByID(id) return r.GetByID(id)
} }
func (r *ApplicationRepository) GetMaterialCurrentStock(materialID int64) (int, error) {
query := `SELECT current_stock FROM materials WHERE id = ?`
var stock int
err := database.DB.QueryRow(query, materialID).Scan(&stock)
return stock, err
}
func (r *ApplicationRepository) GetMaterialCurrentStockTx(tx *sql.Tx, materialID int64) (int, error) {
query := `SELECT current_stock FROM materials WHERE id = ? FOR UPDATE`
var stock int
err := tx.QueryRow(query, materialID).Scan(&stock)
return stock, err
}
func (r *ApplicationRepository) DeductStock(materialID int64, quantity int) error {
query := `UPDATE materials SET current_stock = current_stock - ? WHERE id = ?`
_, err := database.DB.Exec(query, quantity, materialID)
return err
}
func (r *ApplicationRepository) DeductStockTx(tx *sql.Tx, materialID int64, quantity int) error {
query := `UPDATE materials SET current_stock = current_stock - ? WHERE id = ?`
_, err := tx.Exec(query, quantity, materialID)
return err
}
func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error { func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)`
result, err := database.DB.Exec(query, order.Handler, order.Receiver, order.Status) result, err := database.DB.Exec(query, order.Handler, order.Receiver, order.Status)

View File

@ -21,8 +21,11 @@ func NewMaterialRepo() *MaterialRepo {
} }
func (r *MaterialRepo) Create(m *model.Material) error { func (r *MaterialRepo) Create(m *model.Material) error {
query := `INSERT INTO materials (material_code, name, material_image, spec, category, unit, warning_stock) VALUES (?, ?, ?, ?, ?, ?, ?)` query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model,
result, err := database.DB.Exec(query, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, m.Category, m.Unit, m.WarningStock) category_id, base_unit_id, unit, status, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
result, err := database.DB.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "Duplicate entry") { if strings.Contains(err.Error(), "Duplicate entry") {
return fmt.Errorf("material code already exists") return fmt.Errorf("material code already exists")
@ -38,29 +41,45 @@ func (r *MaterialRepo) Create(m *model.Material) error {
} }
func (r *MaterialRepo) GetByID(id int64, m *model.Material) error { func (r *MaterialRepo) GetByID(id int64, m *model.Material) error {
query := `SELECT id, material_code, name, material_image, spec, category, unit, warning_stock, current_stock, created_at, updated_at FROM materials WHERE id = ?` query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE id = ?`
var materialImage sql.NullString var materialImage sql.NullString
err := database.DB.QueryRow(query, id).Scan(&m.ID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &m.Category, &m.Unit, &m.WarningStock, &m.CurrentStock, &m.CreatedAt, &m.UpdatedAt) var manufacturerModel sql.NullString
err := database.DB.QueryRow(query, id).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
&m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
if err != nil { if err != nil {
return err return err
} }
if materialImage.Valid { if materialImage.Valid {
m.MaterialImage = &materialImage.String m.MaterialImage = &materialImage.String
} }
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
return nil return nil
} }
func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) { func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) {
m := &model.Material{} m := &model.Material{}
query := `SELECT id, material_code, name, material_image, spec, category, unit, warning_stock, current_stock, created_at, updated_at FROM materials WHERE material_code = ?` query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE material_code = ?`
var materialImage sql.NullString var materialImage sql.NullString
err := database.DB.QueryRow(query, code).Scan(&m.ID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &m.Category, &m.Unit, &m.WarningStock, &m.CurrentStock, &m.CreatedAt, &m.UpdatedAt) var manufacturerModel sql.NullString
err := database.DB.QueryRow(query, code).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
&m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if materialImage.Valid { if materialImage.Valid {
m.MaterialImage = &materialImage.String m.MaterialImage = &materialImage.String
} }
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
return m, nil return m, nil
} }
@ -73,10 +92,6 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern, searchPattern) countArgs = append(countArgs, searchPattern, searchPattern)
} }
if category != "" {
countQuery += ` AND category = ?`
countArgs = append(countArgs, category)
}
var total int64 var total int64
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
@ -84,7 +99,9 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
return nil, 0, err return nil, 0, err
} }
query := `SELECT id, material_code, name, material_image, spec, category, unit, warning_stock, current_stock, created_at, updated_at FROM materials WHERE 1=1` query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE 1=1`
var args []interface{} var args []interface{}
if search != "" { if search != "" {
@ -92,10 +109,6 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
args = append(args, searchPattern, searchPattern) args = append(args, searchPattern, searchPattern)
} }
if category != "" {
query += ` AND category = ?`
args = append(args, category)
}
query += ` ORDER BY id DESC LIMIT ? OFFSET ?` query += ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset) args = append(args, limit, offset)
@ -109,13 +122,19 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
for rows.Next() { for rows.Next() {
var m model.Material var m model.Material
var materialImage sql.NullString var materialImage sql.NullString
err := rows.Scan(&m.ID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &m.Category, &m.Unit, &m.WarningStock, &m.CurrentStock, &m.CreatedAt, &m.UpdatedAt) var manufacturerModel sql.NullString
err := rows.Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec,
&manufacturerModel, &m.CategoryID, &m.BaseUnitID,
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if materialImage.Valid { if materialImage.Valid {
m.MaterialImage = &materialImage.String m.MaterialImage = &materialImage.String
} }
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
materials = append(materials, m) materials = append(materials, m)
} }
return materials, total, nil return materials, total, nil
@ -125,35 +144,39 @@ func (r *MaterialRepo) Update(id int64, req *model.UpdateMaterialRequest) error
var sets []string var sets []string
var args []interface{} var args []interface{}
if req.MaterialCode != "" {
sets = append(sets, "material_code = ?")
args = append(args, req.MaterialCode)
}
if req.Name != "" { if req.Name != "" {
sets = append(sets, "name = ?") sets = append(sets, "name = ?")
args = append(args, req.Name) args = append(args, req.Name)
} }
if req.MaterialImage != "" { if req.MaterialImage != nil && *req.MaterialImage != "" {
sets = append(sets, "material_image = ?") sets = append(sets, "material_image = ?")
args = append(args, req.MaterialImage) args = append(args, *req.MaterialImage)
} }
if req.Spec != "" { if req.Spec != "" {
sets = append(sets, "spec = ?") sets = append(sets, "spec = ?")
args = append(args, req.Spec) args = append(args, req.Spec)
} }
if req.Category != "" { if req.ManufacturerModel != nil && *req.ManufacturerModel != "" {
sets = append(sets, "category = ?") sets = append(sets, "manufacturer_model = ?")
args = append(args, req.Category) args = append(args, *req.ManufacturerModel)
}
if req.CategoryID != nil {
sets = append(sets, "category_id = ?")
args = append(args, req.CategoryID)
} }
if req.Unit != "" { if req.Unit != "" {
sets = append(sets, "unit = ?") sets = append(sets, "unit = ?")
args = append(args, req.Unit) args = append(args, req.Unit)
} }
if req.WarningStock > 0 { if req.BaseUnitID != nil {
sets = append(sets, "warning_stock = ?") sets = append(sets, "base_unit_id = ?")
args = append(args, req.WarningStock) args = append(args, req.BaseUnitID)
} }
if req.Status != "" {
sets = append(sets, "status = ?")
args = append(args, req.Status)
}
sets = append(sets, "version = version + 1")
if len(sets) == 0 { if len(sets) == 0 {
return nil return nil
} }
@ -190,40 +213,26 @@ func (r *MaterialRepo) Delete(id int64) error {
return err return err
} }
func (r *MaterialRepo) UpdateStock(id int64, delta int) error { func (r *MaterialRepo) Obsolete(id int64) error {
query := `UPDATE materials SET current_stock = current_stock + ? WHERE id = ?` var nonZero int
result, err := database.DB.Exec(query, delta, id) if err := database.DB.QueryRow(`SELECT COUNT(*) FROM inventory_balances WHERE material_id = ? AND (on_hand <> 0 OR reserved <> 0 OR locked <> 0 OR in_transit <> 0)`, id).Scan(&nonZero); err != nil {
if err != nil { return err
return err }
} if nonZero > 0 {
affected, err := result.RowsAffected() return fmt.Errorf("该物料仍有库存,无法作废")
}
_, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id)
return err
}
func (r *MaterialRepo) Restore(id int64) error {
result, err := database.DB.Exec(`UPDATE materials SET status = 'active', version = version + 1 WHERE id = ?`, id)
if err != nil { if err != nil {
return err return err
} }
affected, _ := result.RowsAffected()
if affected == 0 { if affected == 0 {
return sql.ErrNoRows return fmt.Errorf("物料不存在")
} }
return nil return nil
} }
func (r *MaterialRepo) GetStock(id int64) (int, error) {
var stock int
query := `SELECT current_stock FROM materials WHERE id = ?`
err := database.DB.QueryRow(query, id).Scan(&stock)
return stock, err
}
// GetStockForUpdateTx gets stock with row lock within a transaction (prevents race condition)
func (r *MaterialRepo) GetStockForUpdateTx(tx *sql.Tx, id int64) (int, error) {
query := `SELECT current_stock FROM materials WHERE id = ? FOR UPDATE`
var stock int
err := tx.QueryRow(query, id).Scan(&stock)
return stock, err
}
// UpdateStockTx updates stock within a transaction
func (r *MaterialRepo) UpdateStockTx(tx *sql.Tx, id int64, delta int) error {
query := `UPDATE materials SET current_stock = current_stock + ? WHERE id = ?`
_, err := tx.Exec(query, delta, id)
return err
}

View File

@ -22,11 +22,13 @@ func TestMaterialRepoGetByIDReturnsOnlyMasterData(t *testing.T) {
}) })
database.DB = db database.DB = db
query := `SELECT id, material_code, name, material_image, spec, category, unit, created_at, updated_at FROM materials WHERE id = ?` query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE id = ?`
now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(7)).WillReturnRows( mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "material_code", "name", "material_image", "spec", "category", "unit", "created_at", "updated_at"}). sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, "MAT-007", "轴承", nil, "6205", "A02", "件", now, now), AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "active", 2, now, now),
) )
var material model.Material var material model.Material

View File

@ -2,7 +2,9 @@ package repository
import ( import (
"database/sql" "database/sql"
"fmt"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
) )
@ -13,19 +15,15 @@ func NewStockRepo() *StockRepo {
return &StockRepo{} return &StockRepo{}
} }
func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) { func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1` countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1`
var countArgs []interface{} var countArgs []interface{}
if search != "" { if search != "" {
countQuery += ` AND (material_code LIKE ? OR name LIKE ?)` countQuery += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern, searchPattern) countArgs = append(countArgs, searchPattern, searchPattern)
} }
if category != "" {
countQuery += ` AND category = ?`
countArgs = append(countArgs, category)
}
var total int64 var total int64
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
@ -33,20 +31,25 @@ func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]m
return nil, 0, err return nil, 0, err
} }
query := `SELECT id, material_code, name, spec, category, unit, current_stock, warning_stock FROM materials WHERE 1=1` query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0),
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE
LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE 1=1`
var args []interface{} var args []interface{}
if search != "" { if search != "" {
query += ` AND (material_code LIKE ? OR name LIKE ?)` query += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
args = append(args, searchPattern, searchPattern) args = append(args, searchPattern, searchPattern)
} }
if category != "" {
query += ` AND category = ?`
args = append(args, category)
}
query += ` ORDER BY id LIMIT ? OFFSET ?` query += ` GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code ORDER BY m.id LIMIT ? OFFSET ?`
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err := database.DB.Query(query, args...) rows, err := database.DB.Query(query, args...)
@ -58,7 +61,7 @@ func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]m
var stocks []model.Stock var stocks []model.Stock
for rows.Next() { for rows.Next() {
var s model.Stock var s model.Stock
err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Category, &s.Unit, &s.CurrentStock, &s.WarningStock) err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.CurrentStock, &s.WarningStock, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -69,8 +72,18 @@ func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]m
func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) { func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) {
s := &model.Stock{} s := &model.Stock{}
query := `SELECT id, material_code, name, spec, category, unit, current_stock, warning_stock FROM materials WHERE id = ?` query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Category, &s.Unit, &s.CurrentStock, &s.WarningStock) COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0),
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE
LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ?
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code`
err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.CurrentStock, &s.WarningStock, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -78,14 +91,33 @@ func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) {
} }
func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) { func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE current_stock < warning_stock` countQuery := `SELECT COUNT(*) FROM (
SELECT m.id
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE
GROUP BY m.id
HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0)
) warning_materials`
var total int64 var total int64
err := database.DB.QueryRow(countQuery).Scan(&total) err := database.DB.QueryRow(countQuery).Scan(&total)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
query := `SELECT id, material_code, name, spec, category, unit, current_stock, warning_stock FROM materials WHERE current_stock < warning_stock ORDER BY id LIMIT ? OFFSET ?` query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0),
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE
LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code
HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0)
ORDER BY m.id LIMIT ? OFFSET ?`
rows, err := database.DB.Query(query, limit, offset) rows, err := database.DB.Query(query, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -95,7 +127,7 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
var stocks []model.Stock var stocks []model.Stock
for rows.Next() { for rows.Next() {
var s model.Stock var s model.Stock
err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Category, &s.Unit, &s.CurrentStock, &s.WarningStock) err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.CurrentStock, &s.WarningStock, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -229,15 +261,17 @@ func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog,
// Get initialBalance: current_stock of this material minus all deltas for this material. // Get initialBalance: current_stock of this material minus all deltas for this material.
// This gives the balance BEFORE the oldest log in this page was applied. // This gives the balance BEFORE the oldest log in this page was applied.
initialBalances := make(map[int64]int) initialBalances := make(map[int64]decimal.Decimal)
for mid := range materialIDs { for mid := range materialIDs {
var delta int var delta int
database.DB.QueryRow(` database.DB.QueryRow(`
SELECT COALESCE(SUM(CASE WHEN type='in' THEN quantity ELSE -quantity END), 0) SELECT COALESCE(SUM(CASE WHEN type='in' THEN quantity ELSE -quantity END), 0)
FROM stock_logs WHERE material_id = ?`, mid).Scan(&delta) FROM stock_logs WHERE material_id = ?`, mid).Scan(&delta)
var currentStock int var currentStock decimal.Decimal
database.DB.QueryRow(`SELECT current_stock FROM materials WHERE id = ?`, mid).Scan(&currentStock) if err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, mid).Scan(&currentStock); err != nil {
initialBalances[mid] = currentStock - delta return nil, 0, err
}
initialBalances[mid] = currentStock.Sub(decimal.NewFromInt(int64(delta)))
} }
// Process from OLDEST to NEWEST within the page (reverse order of our DESC result). // Process from OLDEST to NEWEST within the page (reverse order of our DESC result).
@ -247,9 +281,9 @@ func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog,
r := &pageRows[i] r := &pageRows[i]
bal := initialBalances[r.log.MaterialID] bal := initialBalances[r.log.MaterialID]
if r.log.Type == "in" { if r.log.Type == "in" {
bal += r.log.Quantity bal = bal.Add(decimal.NewFromInt(int64(r.log.Quantity)))
} else { } else {
bal -= r.log.Quantity bal = bal.Sub(decimal.NewFromInt(int64(r.log.Quantity)))
} }
r.log.RunningBalance = bal r.log.RunningBalance = bal
initialBalances[r.log.MaterialID] = bal initialBalances[r.log.MaterialID] = bal
@ -262,6 +296,74 @@ func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog,
return logs, total, nil return logs, total, nil
} }
// GetStock returns the current on-hand balance for a material across inventory dimensions.
func (r *StockRepo) GetStock(materialID int64) (decimal.Decimal, error) {
var stock decimal.Decimal
err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, materialID).Scan(&stock)
return stock, err
}
// GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows.
func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) {
query := `SELECT ib.on_hand
FROM inventory_balances ib
JOIN organizations o ON o.id = ib.organization_id AND o.code = 'DEFAULT'
JOIN warehouses w ON w.id = ib.warehouse_id AND w.code = 'DEFAULT'
JOIN warehouse_locations l ON l.id = ib.location_id AND l.code = 'MAIN'
WHERE ib.material_id = ? FOR UPDATE`
var stock decimal.Decimal
err := tx.QueryRow(query, materialID).Scan(&stock)
if err == sql.ErrNoRows {
return decimal.Zero, nil
}
return stock, err
}
// UpdateStockTx writes the legacy inbound/outbound change to the independent default inventory balance.
func (r *StockRepo) UpdateStockTx(tx *sql.Tx, materialID int64, delta decimal.Decimal) error {
query := `INSERT INTO inventory_balances (organization_id, warehouse_id, location_id, material_id, on_hand)
SELECT o.id, w.id, l.id, ?, ?
FROM organizations o
JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT'
JOIN warehouse_locations l ON l.warehouse_id = w.id AND l.code = 'MAIN'
WHERE o.code = 'DEFAULT'
ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1`
result, err := tx.Exec(query, materialID, delta)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("default organization, warehouse, or location is missing")
}
return nil
}
// UpdateWarningStock stores the default warehouse threshold in the inventory domain.
func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal) error {
query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled)
SELECT o.id, w.id, ?, ?, TRUE
FROM organizations o
JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT'
WHERE o.code = 'DEFAULT'
ON DUPLICATE KEY UPDATE minimum_available = VALUES(minimum_available), enabled = TRUE`
result, err := database.DB.Exec(query, materialID, minimumAvailable)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("default organization or warehouse is missing")
}
return nil
}
func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error { func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error {
query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?` query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?`
_, err := database.DB.Exec(query, orderType, orderID) _, err := database.DB.Exec(query, orderType, orderID)
@ -289,3 +391,33 @@ func (r *StockRepo) DeleteLogsTx(tx *sql.Tx, orderType string, orderID int64) er
_, err := tx.Exec(query, orderType, orderID) _, err := tx.Exec(query, orderType, orderID)
return err return err
} }
func (r *StockRepo) GetInventoryBalanceDetails(materialID int64) ([]model.InventoryBalanceDetail, error) {
query := `
SELECT ib.id, ib.organization_id, o.name, ib.warehouse_id, w.name,
ib.location_id, l.name, ib.material_id, ib.on_hand, ib.reserved,
ib.locked, ib.in_transit, ib.updated_at
FROM inventory_balances ib
JOIN organizations o ON o.id = ib.organization_id
JOIN warehouses w ON w.id = ib.warehouse_id
JOIN warehouse_locations l ON l.id = ib.location_id
WHERE ib.material_id = ?
ORDER BY ib.id`
rows, err := database.DB.Query(query, materialID)
if err != nil {
return nil, err
}
defer rows.Close()
var details []model.InventoryBalanceDetail
for rows.Next() {
var d model.InventoryBalanceDetail
if err := rows.Scan(&d.ID, &d.OrganizationID, &d.Organization, &d.WarehouseID, &d.Warehouse,
&d.LocationID, &d.Location, &d.MaterialID, &d.OnHand, &d.Reserved,
&d.Locked, &d.InTransit, &d.UpdatedAt); err != nil {
return nil, err
}
details = append(details, d)
}
return details, rows.Err()
}

View File

@ -5,17 +5,21 @@ import (
"testing" "testing"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
) )
const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.category, m.unit, const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0) COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0),
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE
LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ? WHERE m.id = ?
GROUP BY m.id, m.material_code, m.name, m.spec, m.category, m.unit` GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code`
func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) { func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
@ -29,22 +33,50 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
database.DB = db database.DB = db
mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows( mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "category", "unit", "on_hand", "minimum_available"}). sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "major_category", "minor_category"}).
AddRow(9, "MAT-009", "电机", "80W", "A03", "台", 12, 3), AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", "B01", "CAT-SUB-027"),
) )
stock, err := NewStockRepo().GetByMaterialID(9) stock, err := NewStockRepo().GetByMaterialID(9)
if err != nil { if err != nil {
t.Fatalf("get stock: %v", err) t.Fatalf("get stock: %v", err)
} }
if stock.CurrentStock != 12 || stock.WarningStock != 3 { if !stock.CurrentStock.Equal(decimal.RequireFromString("12.500000")) || !stock.WarningStock.Equal(decimal.NewFromInt(3)) {
t.Fatalf("unexpected stock quantities: %+v", stock) t.Fatalf("unexpected stock quantities: %+v", stock)
} }
if stock.MajorCategory != "B01" || stock.MinorCategory != "CAT-SUB-027" {
t.Fatalf("unexpected stock categories: %+v", stock)
}
if err := mock.ExpectationsWereMet(); err != nil { if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestStockRepoGetStockPreservesDecimalQuantity(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
query := `SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"on_hand"}).AddRow("1500.250000"),
)
stock, err := NewStockRepo().GetStock(9)
if err != nil {
t.Fatalf("get stock: %v", err)
}
if !stock.Equal(decimal.RequireFromString("1500.250000")) {
t.Fatalf("stock = %s, want 1500.250000", stock)
}
}
func TestStockRepoUpdateStockTxWritesDefaultInventoryBalance(t *testing.T) { func TestStockRepoUpdateStockTxWritesDefaultInventoryBalance(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@ -68,12 +100,39 @@ func TestStockRepoUpdateStockTxWritesDefaultInventoryBalance(t *testing.T) {
JOIN warehouse_locations l ON l.warehouse_id = w.id AND l.code = 'MAIN' JOIN warehouse_locations l ON l.warehouse_id = w.id AND l.code = 'MAIN'
WHERE o.code = 'DEFAULT' WHERE o.code = 'DEFAULT'
ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1` ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1`
mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(11), 5).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(11), "5.5").WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateStockTx(tx, 11, 5); err != nil { if err := NewStockRepo().UpdateStockTx(tx, 11, decimal.RequireFromString("5.500000")); err != nil {
t.Fatalf("update inventory balance: %v", err) t.Fatalf("update inventory balance: %v", err)
} }
if err := mock.ExpectationsWereMet(); err != nil { if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestStockRepoUpdateWarningStockWritesInventoryRule(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
query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled)
SELECT o.id, w.id, ?, ?, TRUE
FROM organizations o
JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT'
WHERE o.code = 'DEFAULT'
ON DUPLICATE KEY UPDATE minimum_available = VALUES(minimum_available), enabled = TRUE`
mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(13), "8.75").WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000")); err != nil {
t.Fatalf("update stock warning: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -1,68 +1,72 @@
package repository package repository
import ( import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var (
ErrInvalidRoles = errors.New("one or more roles do not exist or are disabled")
ErrLastSuperAdmin = errors.New("the last super administrator cannot be removed")
)
type UserRepository struct{} type UserRepository struct{}
func NewUserRepository() *UserRepository { func NewUserRepository() *UserRepository { return &UserRepository{} }
return &UserRepository{}
}
func (r *UserRepository) GetByUsername(username string) (*model.User, error) { func (r *UserRepository) GetByUsername(username string) (*model.User, error) {
query := `SELECT id, username, password, role, description, created_at FROM users WHERE username = ?` user := &model.User{}
err := database.DB.QueryRow(`SELECT id, username, password, description, created_at FROM users WHERE username = ?`, username).Scan(
var user model.User &user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt,
err := database.DB.QueryRow(query, username).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &user, nil if err := loadRolesForUsers(database.DB, []*model.User{user}); err != nil {
return nil, err
}
return user, nil
} }
func (r *UserRepository) GetByID(id int64) (*model.User, error) { func (r *UserRepository) GetByID(id int64) (*model.User, error) {
query := `SELECT id, username, password, role, description, created_at FROM users WHERE id = ?` user := &model.User{}
err := database.DB.QueryRow(`SELECT id, username, password, description, created_at FROM users WHERE id = ?`, id).Scan(
var user model.User &user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt,
err := database.DB.QueryRow(query, id).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &user, nil if err := loadRolesForUsers(database.DB, []*model.User{user}); err != nil {
return nil, err
}
return user, nil
} }
func (r *UserRepository) Create(user *model.User) error { func (r *UserRepository) Create(user *model.User, roleIDs []int64) error {
query := `INSERT INTO users (username, password, role, description) VALUES (?, ?, ?, ?)` tx, err := database.DB.Begin()
result, err := database.DB.Exec(query, user.Username, user.Password, user.Role, user.Description)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = tx.Rollback() }()
id, err := result.LastInsertId() result, err := tx.Exec(`INSERT INTO users (username, password, description) VALUES (?, ?, ?)`, user.Username, user.Password, user.Description)
if err != nil { if err != nil {
return err return err
} }
user.ID, err = result.LastInsertId()
user.ID = id if err != nil {
return nil return err
}
if err = assignRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
return tx.Commit()
} }
func (r *UserRepository) List(search string, limit, offset int) ([]model.User, int64, error) { func (r *UserRepository) List(search string, limit, offset int) ([]model.User, int64, error) {
@ -70,21 +74,18 @@ func (r *UserRepository) List(search string, limit, offset int) ([]model.User, i
var countArgs []interface{} var countArgs []interface{}
if search != "" { if search != "" {
countQuery += ` WHERE username LIKE ?` countQuery += ` WHERE username LIKE ?`
searchPattern := "%" + search + "%" countArgs = append(countArgs, "%"+search+"%")
countArgs = append(countArgs, searchPattern)
} }
var total int64 var total int64
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
if err != nil {
return nil, 0, err return nil, 0, err
} }
query := `SELECT id, username, password, role, description, created_at FROM users` query := `SELECT id, username, password, description, created_at FROM users`
var args []interface{} var args []interface{}
if search != "" { if search != "" {
query += ` WHERE username LIKE ?` query += ` WHERE username LIKE ?`
searchPattern := "%" + search + "%" args = append(args, "%"+search+"%")
args = append(args, searchPattern)
} }
query += ` ORDER BY id LIMIT ? OFFSET ?` query += ` ORDER BY id LIMIT ? OFFSET ?`
args = append(args, limit, offset) args = append(args, limit, offset)
@ -94,111 +95,247 @@ func (r *UserRepository) List(search string, limit, offset int) ([]model.User, i
} }
defer rows.Close() defer rows.Close()
var users []model.User users := make([]model.User, 0)
for rows.Next() { for rows.Next() {
var user model.User var user model.User
err := rows.Scan( if err := rows.Scan(&user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt); err != nil {
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
)
if err != nil {
return nil, 0, err return nil, 0, err
} }
users = append(users, user) users = append(users, user)
} }
if err := rows.Err(); err != nil {
return nil, 0, err
}
userPointers := make([]*model.User, 0, len(users))
for index := range users {
userPointers = append(userPointers, &users[index])
}
if err := loadRolesForUsers(database.DB, userPointers); err != nil {
return nil, 0, err
}
return users, total, nil return users, total, nil
} }
func (r *UserRepository) Delete(id int64) error { func (r *UserRepository) Update(user *model.User, roleIDs []int64) error {
query := `DELETE FROM users WHERE id = ?` return r.update(user, roleIDs, false)
_, err := database.DB.Exec(query, id)
return err
} }
func (r *UserRepository) Update(user *model.User) error { func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64) error {
query := `UPDATE users SET role = ?, description = ? WHERE id = ?` return r.update(user, roleIDs, true)
_, err := database.DB.Exec(query, user.Role, user.Description, user.ID)
return err
} }
func (r *UserRepository) UpdateWithPassword(user *model.User) error { func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePassword bool) error {
query := `UPDATE users SET password = ?, role = ?, description = ? WHERE id = ?` tx, err := database.DB.Begin()
_, err := database.DB.Exec(query, user.Password, user.Role, user.Description, user.ID)
return err
}
// UserExists checks if a user exists by username
func (r *UserRepository) UserExists(username string) (bool, error) {
query := `SELECT COUNT(*) FROM users WHERE username = ?`
var count int
err := database.DB.QueryRow(query, username).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// InitUserTable creates the users table if it doesn't exist
func InitUserTable() error {
query := `
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin','user') NOT NULL DEFAULT 'user',
description VARCHAR(500) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`
_, err := database.DB.Exec(query)
return err
}
// CreateDefaultUsers creates default admin and user if no users exist
func CreateDefaultUsers() error {
repo := NewUserRepository()
exists, err := repo.UserExists("admin")
if err != nil { if err != nil {
return err return err
} }
if exists { defer func() { _ = tx.Rollback() }()
roleIDs = uniqueRoleIDs(roleIDs)
if err := validateRolesTx(tx, roleIDs); err != nil {
return err
}
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
return err
}
if updatePassword {
_, err = tx.Exec(`UPDATE users SET password = ?, description = ? WHERE id = ?`, user.Password, user.Description, user.ID)
} else {
_, err = tx.Exec(`UPDATE users SET description = ? WHERE id = ?`, user.Description, user.ID)
}
if err != nil {
return err
}
if _, err = tx.Exec(`DELETE FROM user_roles WHERE user_id = ?`, user.ID); err != nil {
return err
}
if err = insertUserRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
return tx.Commit()
}
func (r *UserRepository) Delete(id int64) error {
tx, err := database.DB.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := protectLastSuperAdminTx(tx, id, nil); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
func loadRolesForUsers(queryer interface {
Query(query string, args ...interface{}) (*sql.Rows, error)
}, users []*model.User) error {
if len(users) == 0 {
return nil return nil
} }
indexes := make(map[int64]*model.User, len(users))
// Default passwords will be hashed in CreateUser args := make([]interface{}, 0, len(users))
admin := &model.User{ for _, user := range users {
Username: "admin", user.Roles = []model.Role{}
Password: "", // Will be set by service indexes[user.ID] = user
Role: "admin", args = append(args, user.ID)
Description: "系统管理员",
} }
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(args)), ", ")
query := fmt.Sprintf(`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 (%s) ORDER BY ur.user_id, r.id`, placeholders)
rows, err := queryer.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID int64
var role model.Role
if err := rows.Scan(&userID, &role.ID, &role.Code, &role.Name, &role.Enabled, &role.CreatedAt, &role.UpdatedAt); err != nil {
return err
}
role.Permissions = []model.Permission{}
if user := indexes[userID]; user != nil {
user.Roles = append(user.Roles, role)
}
}
return rows.Err()
}
// Use bcrypt to hash the default passwords func assignRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
roleIDs = uniqueRoleIDs(roleIDs)
if err := validateRolesTx(tx, roleIDs); err != nil {
return err
}
return insertUserRolesTx(tx, userID, roleIDs)
}
func validateRolesTx(tx *sql.Tx, roleIDs []int64) error {
if len(roleIDs) == 0 {
return ErrInvalidRoles
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(roleIDs)), ", ")
args := make([]interface{}, len(roleIDs))
for index, id := range roleIDs {
args[index] = id
}
var count int
query := fmt.Sprintf(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (%s)`, placeholders)
if err := tx.QueryRow(query, args...).Scan(&count); err != nil {
return err
}
if count != len(roleIDs) {
return ErrInvalidRoles
}
return nil
}
func insertUserRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
values := strings.TrimSuffix(strings.Repeat("(?, ?), ", len(roleIDs)), ", ")
args := make([]interface{}, 0, len(roleIDs)*2)
for _, roleID := range roleIDs {
args = append(args, userID, roleID)
}
_, err := tx.Exec(`INSERT INTO user_roles (user_id, role_id) VALUES `+values, args...)
return err
}
func protectLastSuperAdminTx(tx *sql.Tx, userID int64, replacementRoleIDs []int64) error {
var superAdminRoleID int64
if err := tx.QueryRow(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`).Scan(&superAdminRoleID); err != nil {
return err
}
for _, roleID := range replacementRoleIDs {
if roleID == superAdminRoleID {
return nil
}
}
var currentMembership int
if err := tx.QueryRow(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`, userID, superAdminRoleID).Scan(&currentMembership); err != nil {
return err
}
if currentMembership == 0 {
return nil
}
var superAdminCount int
if err := tx.QueryRow(`SELECT COUNT(*) FROM user_roles WHERE role_id = ?`, superAdminRoleID).Scan(&superAdminCount); err != nil {
return err
}
if superAdminCount <= 1 {
return ErrLastSuperAdmin
}
return nil
}
func uniqueRoleIDs(roleIDs []int64) []int64 {
seen := make(map[int64]struct{}, len(roleIDs))
result := make([]int64, 0, len(roleIDs))
for _, id := range roleIDs {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func (r *UserRepository) UserExists(username string) (bool, error) {
var count int
err := database.DB.QueryRow(`SELECT COUNT(*) FROM users WHERE username = ?`, username).Scan(&count)
return count > 0, err
}
func InitUserTable() error {
_, err := database.DB.Exec(`CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
description VARCHAR(500) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
return err
}
func CreateDefaultUsers() error {
repo := NewUserRepository()
exists, err := repo.UserExists("admin")
if err != nil || exists {
return err
}
adminRoleID, err := roleIDByCode("super_admin")
if err != nil {
return err
}
warehouseRoleID, err := roleIDByCode("warehouse_admin")
if err != nil {
return err
}
adminHash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) adminHash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil { if err != nil {
return err return err
} }
admin.Password = string(adminHash) admin := &model.User{Username: "admin", Password: string(adminHash), Description: "系统管理员"}
if err := repo.Create(admin, []int64{adminRoleID}); err != nil {
if err := repo.Create(admin); err != nil {
return err return err
} }
userHash, err := bcrypt.GenerateFromPassword([]byte("user123"), bcrypt.DefaultCost) userHash, err := bcrypt.GenerateFromPassword([]byte("user123"), bcrypt.DefaultCost)
if err != nil { if err != nil {
return err return err
} }
user := &model.User{ user := &model.User{Username: "user", Password: string(userHash), Description: "普通用户"}
Username: "user", return repo.Create(user, []int64{warehouseRoleID})
Password: string(userHash), }
Role: "user",
Description: "普通用户", func roleIDByCode(code string) (int64, error) {
} var id int64
err := database.DB.QueryRow(`SELECT id FROM roles WHERE code = ? AND enabled = TRUE`, code).Scan(&id)
return repo.Create(user) return id, err
} }

View File

@ -1,6 +1,7 @@
package repository package repository
import ( import (
"errors"
"regexp" "regexp"
"testing" "testing"
"time" "time"
@ -49,6 +50,66 @@ func TestListUsersIncludesAllAssignedRoles(t *testing.T) {
} }
} }
func TestDeleteRejectsLastSuperAdmin(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 id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
WithArgs(int64(7), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE role_id = ?`)).
WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectRollback()
err = NewUserRepository().Delete(7)
if !errors.Is(err, ErrLastSuperAdmin) {
t.Fatalf("delete error = %v, want ErrLastSuperAdmin", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUpdateRejectsRemovingRoleFromLastSuperAdmin(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 COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?)`)).
WithArgs(int64(2)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
WithArgs(int64(7), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE role_id = ?`)).
WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectRollback()
user := &model.User{ID: 7, Description: "系统管理员"}
err = NewUserRepository().Update(user, []int64{2})
if !errors.Is(err, ErrLastSuperAdmin) {
t.Fatalf("update error = %v, want ErrLastSuperAdmin", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) { func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {

View File

@ -3,23 +3,28 @@ package service
import ( import (
"errors" "errors"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
) )
var ( var (
ErrApplicationNotFound = errors.New("application not found") ErrApplicationNotFound = errors.New("application not found")
ErrCannotCancelApplication = errors.New("cannot cancel application") ErrCannotCancelApplication = errors.New("cannot cancel application")
ErrApplicationNotPending = errors.New("application is not pending") ErrApplicationNotPending = errors.New("application is not pending")
) )
type ApplicationService struct { type ApplicationService struct {
appRepo *repository.ApplicationRepository appRepo *repository.ApplicationRepository
materialSvc *MaterialService
stockRepo *repository.StockRepo
} }
func NewApplicationService() *ApplicationService { func NewApplicationService() *ApplicationService {
return &ApplicationService{ return &ApplicationService{
appRepo: repository.NewApplicationRepository(), appRepo: repository.NewApplicationRepository(),
materialSvc: NewMaterialService(),
stockRepo: repository.NewStockRepo(),
} }
} }
@ -27,9 +32,8 @@ func (s *ApplicationService) Create(userID int64, req *model.CreateApplicationRe
// Build items from request // Build items from request
var items []model.ApplicationItem var items []model.ApplicationItem
for _, item := range req.Items { for _, item := range req.Items {
// Check material exists and get stock exists, err := s.materialSvc.Exists(item.MaterialID)
_, err := s.appRepo.GetMaterialCurrentStock(item.MaterialID) if err != nil || !exists {
if err != nil {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
items = append(items, model.ApplicationItem{ items = append(items, model.ApplicationItem{
@ -95,11 +99,11 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
// Check stock for all items // Check stock for all items
for _, item := range app.Items { for _, item := range app.Items {
stock, err := s.appRepo.GetMaterialCurrentStock(item.MaterialID) stock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil { if err != nil {
return ErrMaterialNotFound return ErrMaterialNotFound
} }
if stock < item.Quantity { if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return ErrInsufficientStock return ErrInsufficientStock
} }
} }
@ -133,13 +137,17 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
// Process each item within transaction // Process each item within transaction
for _, appItem := range app.Items { for _, appItem := range app.Items {
// Re-check stock with row lock // Re-check stock with row lock
_, err = s.appRepo.GetMaterialCurrentStockTx(tx, appItem.MaterialID) lockedStock, lockErr := s.stockRepo.GetStockForUpdateTx(tx, appItem.MaterialID)
err = lockErr
if err != nil { if err != nil {
return ErrMaterialNotFound return ErrMaterialNotFound
} }
if lockedStock.LessThan(decimal.NewFromInt(int64(appItem.Quantity))) {
return ErrInsufficientStock
}
// Deduct stock within transaction // Deduct stock within transaction
if err := s.appRepo.DeductStockTx(tx, appItem.MaterialID, appItem.Quantity); err != nil { if err := s.stockRepo.UpdateStockTx(tx, appItem.MaterialID, decimal.NewFromInt(int64(-appItem.Quantity))); err != nil {
return err return err
} }
@ -216,6 +224,6 @@ func (s *ApplicationService) Cancel(id int64, userID int64) error {
return s.appRepo.Cancel(id) return s.appRepo.Cancel(id)
} }
func (s *ApplicationService) GetMaterialStock(materialID int64) (int, error) { func (s *ApplicationService) GetMaterialStock(materialID int64) (decimal.Decimal, error) {
return s.appRepo.GetMaterialCurrentStock(materialID) return s.stockRepo.GetStock(materialID)
} }

View File

@ -30,20 +30,26 @@ func NewAuthService() *AuthService {
// SimpleToken represents a simple JWT-like token // SimpleToken represents a simple JWT-like token
type SimpleToken struct { type SimpleToken struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` Roles []string `json:"roles"`
Exp int64 `json:"exp"` Exp int64 `json:"exp"`
} }
// GenerateToken creates a simple token for the user // GenerateToken creates a simple token for the user
func (s *AuthService) GenerateToken(user *model.User) (string, error) { func (s *AuthService) GenerateToken(user *model.User) (string, error) {
exp := time.Now().Add(24 * time.Hour).Unix() exp := time.Now().Add(24 * time.Hour).Unix()
roleCodes := make([]string, 0, len(user.Roles))
for _, role := range user.Roles {
if role.Enabled {
roleCodes = append(roleCodes, role.Code)
}
}
token := SimpleToken{ token := SimpleToken{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
Role: user.Role, Roles: roleCodes,
Exp: exp, Exp: exp,
} }
@ -152,7 +158,7 @@ func (s *AuthService) ListUsers(search string, limit, offset int) ([]model.User,
} }
// CreateUser creates a new user // CreateUser creates a new user
func (s *AuthService) CreateUser(username, password, role, description string) (*model.User, error) { func (s *AuthService) CreateUser(username, password, description string, roleIDs []int64) (*model.User, error) {
// Hash password // Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -162,25 +168,22 @@ func (s *AuthService) CreateUser(username, password, role, description string) (
user := &model.User{ user := &model.User{
Username: username, Username: username,
Password: string(hashedPassword), Password: string(hashedPassword),
Role: role,
Description: description, Description: description,
} }
if err := s.userRepo.Create(user); err != nil { if err := s.userRepo.Create(user, roleIDs); err != nil {
return nil, err return nil, err
} }
return s.userRepo.GetByID(user.ID)
return user, nil
} }
// UpdateUser updates a user (admin function) // UpdateUser updates a user (admin function)
func (s *AuthService) UpdateUser(id int64, username, role, description string, newPassword string) error { func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64, newPassword string) error {
user, err := s.userRepo.GetByID(id) user, err := s.userRepo.GetByID(id)
if err != nil { if err != nil {
return err return err
} }
user.Role = role
user.Description = description user.Description = description
if newPassword != "" { if newPassword != "" {
@ -189,10 +192,10 @@ func (s *AuthService) UpdateUser(id int64, username, role, description string, n
return err return err
} }
user.Password = string(hashedPassword) user.Password = string(hashedPassword)
return s.userRepo.UpdateWithPassword(user) return s.userRepo.UpdateWithPassword(user, roleIDs)
} }
return s.userRepo.Update(user) return s.userRepo.Update(user, roleIDs)
} }
// DeleteUser deletes a user // DeleteUser deletes a user

View File

@ -3,14 +3,15 @@ package service
import ( import (
"time" "time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
) )
type InboundService struct { type InboundService struct {
inboundRepo *repository.InboundRepo inboundRepo *repository.InboundRepo
materialSvc *MaterialService materialSvc *MaterialService
stockRepo *repository.StockRepo stockRepo *repository.StockRepo
} }
func NewInboundService() *InboundService { func NewInboundService() *InboundService {
@ -71,7 +72,7 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
item.MaterialCode = material.MaterialCode item.MaterialCode = material.MaterialCode
item.MaterialName = material.Name item.MaterialName = material.Name
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -130,7 +131,7 @@ func (s *InboundService) Delete(id int64) error {
// Reverse stock for each item within transaction // Reverse stock for each item within transaction
for _, item := range order.Items { for _, item := range order.Items {
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, -item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
if err != nil { if err != nil {
return err return err
} }
@ -176,11 +177,11 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
// Reverse stock for old items (subtract what was previously added) // Reverse stock for old items (subtract what was previously added)
for _, item := range oldOrder.Items { for _, item := range oldOrder.Items {
_, err = s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID) _, err = s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, -item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -200,11 +201,11 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
// Add stock for new items and create logs // Add stock for new items and create logs
for _, item := range req.Items { for _, item := range req.Items {
_, err = s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID) _, err = s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -9,8 +9,8 @@ import (
) )
type MaterialService struct { type MaterialService struct {
repo *repository.MaterialRepo repo *repository.MaterialRepo
logSvc *MaterialLogService logSvc *MaterialLogService
} }
func NewMaterialService() *MaterialService { func NewMaterialService() *MaterialService {
@ -21,24 +21,34 @@ func NewMaterialService() *MaterialService {
} }
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) { func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
organizationID := req.OrganizationID
if organizationID == 0 {
organizationID = 1
}
unit := req.Unit unit := req.Unit
if unit == "" { if unit == "" {
unit = "pcs" unit = "pcs"
} }
var materialImage *string var materialImage *string
if req.MaterialImage != "" { if req.MaterialImage != nil {
materialImage = &req.MaterialImage materialImage = req.MaterialImage
} }
m := &model.Material{ m := &model.Material{
MaterialCode: req.MaterialCode, OrganizationID: organizationID,
Name: req.Name, MaterialCode: req.MaterialCode,
MaterialImage: materialImage, Name: req.Name,
Spec: req.Spec, MaterialImage: materialImage,
Category: req.Category, Spec: req.Spec,
Unit: unit, CategoryID: req.CategoryID,
WarningStock: req.WarningStock, BaseUnitID: req.BaseUnitID,
Unit: unit,
Status: "active",
Version: 1,
}
if req.ManufacturerModel != nil {
m.ManufacturerModel = req.ManufacturerModel
} }
err := s.repo.Create(m) err := s.repo.Create(m)
@ -54,6 +64,38 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
return m, nil return m, nil
} }
func (s *MaterialService) Obsolete(id int64, operator string) error {
material, _ := s.GetByID(id)
if err := s.repo.Obsolete(id); err != nil {
return err
}
if material != nil {
updated := *material
updated.Status = "obsolete"
updated.Version++
if err := s.logSvc.LogUpdate(&updated, material, operator); err != nil {
log.Printf("Warning: failed to create material log: %v", err)
}
}
return nil
}
func (s *MaterialService) Restore(id int64, operator string) error {
material, _ := s.GetByID(id)
if err := s.repo.Restore(id); err != nil {
return err
}
if material != nil {
updated := *material
updated.Status = "active"
updated.Version++
if err := s.logSvc.LogUpdate(&updated, material, operator); err != nil {
log.Printf("Warning: failed to create material log: %v", err)
}
}
return nil
}
func (s *MaterialService) GetByID(id int64) (*model.Material, error) { func (s *MaterialService) GetByID(id int64) (*model.Material, error) {
m := &model.Material{} m := &model.Material{}
err := s.repo.GetByID(id, m) err := s.repo.GetByID(id, m)
@ -112,14 +154,6 @@ func (s *MaterialService) Delete(id int64, operator string) error {
return nil return nil
} }
func (s *MaterialService) GetStock(id int64) (int, error) {
return s.repo.GetStock(id)
}
func (s *MaterialService) UpdateStock(id int64, delta int) error {
return s.repo.UpdateStock(id, delta)
}
func (s *MaterialService) Exists(id int64) (bool, error) { func (s *MaterialService) Exists(id int64) (bool, error) {
m := &model.Material{} m := &model.Material{}
err := s.repo.GetByID(id, m) err := s.repo.GetByID(id, m)
@ -131,13 +165,3 @@ func (s *MaterialService) Exists(id int64) (bool, error) {
} }
return true, nil return true, nil
} }
// GetStockForUpdateTx gets stock with row lock within a transaction
func (s *MaterialService) GetStockForUpdateTx(tx *sql.Tx, id int64) (int, error) {
return s.repo.GetStockForUpdateTx(tx, id)
}
// UpdateStockTx updates stock within a transaction
func (s *MaterialService) UpdateStockTx(tx *sql.Tx, id int64, delta int) error {
return s.repo.UpdateStockTx(tx, id, delta)
}

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"time" "time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
) )
@ -20,7 +21,7 @@ func NewOutboundService() *OutboundService {
return &OutboundService{ return &OutboundService{
outboundRepo: repository.NewOutboundRepo(), outboundRepo: repository.NewOutboundRepo(),
materialSvc: NewMaterialService(), materialSvc: NewMaterialService(),
stockRepo: repository.NewStockRepo(), stockRepo: repository.NewStockRepo(),
} }
} }
@ -42,11 +43,11 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.Outbo
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
currentStock, err := s.materialSvc.GetStock(item.MaterialID) currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if currentStock < item.Quantity { if currentStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return nil, ErrInsufficientStock return nil, ErrInsufficientStock
} }
@ -70,11 +71,11 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.Outbo
// Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling // Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling
for i := range order.Items { for i := range order.Items {
item := &order.Items[i] item := &order.Items[i]
lockedStock, err := s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID) lockedStock, err := s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if lockedStock < item.Quantity { if lockedStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
err = ErrInsufficientStock err = ErrInsufficientStock
return nil, err return nil, err
} }
@ -97,7 +98,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.Outbo
item.MaterialName = material.Name item.MaterialName = material.Name
// Update stock within transaction // Update stock within transaction
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, -item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -157,7 +158,7 @@ func (s *OutboundService) Delete(id int64) error {
// Reverse stock for each item (add back) within transaction // Reverse stock for each item (add back) within transaction
for _, item := range order.Items { for _, item := range order.Items {
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil { if err != nil {
return err return err
} }
@ -200,7 +201,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
currentStock, err := s.materialSvc.GetStock(item.MaterialID) currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -212,8 +213,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
break break
} }
} }
effectiveStock := currentStock + oldQty effectiveStock := currentStock.Add(decimal.NewFromInt(int64(oldQty)))
if effectiveStock < item.Quantity { if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return nil, ErrInsufficientStock return nil, ErrInsufficientStock
} }
} }
@ -231,7 +232,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
// Reverse stock for old items (add back what was previously subtracted) // Reverse stock for old items (add back what was previously subtracted)
for _, item := range oldOrder.Items { for _, item := range oldOrder.Items {
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -239,7 +240,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
// Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling // Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling
for _, item := range req.Items { for _, item := range req.Items {
lockedStock, err := s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID) lockedStock, err := s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -250,8 +251,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
break break
} }
} }
effectiveStock := lockedStock + oldQty effectiveStock := lockedStock.Add(decimal.NewFromInt(int64(oldQty)))
if effectiveStock < item.Quantity { if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
err = ErrInsufficientStock err = ErrInsufficientStock
return nil, err return nil, err
} }
@ -271,7 +272,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
// Deduct stock for new items and create logs // Deduct stock for new items and create logs
for _, item := range req.Items { for _, item := range req.Items {
err = s.materialSvc.UpdateStockTx(tx, item.MaterialID, -item.Quantity) err = s.stockRepo.UpdateStockTx(tx, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity)))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -3,6 +3,7 @@ package service
import ( import (
"database/sql" "database/sql"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
) )
@ -17,8 +18,8 @@ func NewStockService() *StockService {
} }
} }
func (s *StockService) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) { func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
return s.stockRepo.GetAllStock(search, category, limit, offset) return s.stockRepo.GetAllStock(search, limit, offset)
} }
func (s *StockService) GetByMaterialID(materialID int64) (*model.Stock, error) { func (s *StockService) GetByMaterialID(materialID int64) (*model.Stock, error) {
@ -29,6 +30,10 @@ func (s *StockService) GetWarningStock(limit, offset int) ([]model.Stock, int64,
return s.stockRepo.GetWarningStock(limit, offset) return s.stockRepo.GetWarningStock(limit, offset)
} }
func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal) error {
return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable)
}
func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) { func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) {
logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset) logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset)
if err != nil { if err != nil {
@ -36,12 +41,13 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
} }
var responses []model.StockLogResponse var responses []model.StockLogResponse
balance := 0 balance := decimal.Zero
for _, log := range logs { for _, log := range logs {
quantity := decimal.NewFromInt(int64(log.Quantity))
if log.Type == "in" { if log.Type == "in" {
balance += log.Quantity balance = balance.Add(quantity)
} else { } else {
balance -= log.Quantity balance = balance.Sub(quantity)
} }
changeType := "outbound" changeType := "outbound"
@ -49,16 +55,16 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
changeType = "inbound" changeType = "inbound"
} }
beforeQuantity := balance - log.Quantity beforeQuantity := balance.Sub(quantity)
if log.Type != "in" { if log.Type != "in" {
beforeQuantity = balance + log.Quantity beforeQuantity = balance.Add(quantity)
} }
responses = append(responses, model.StockLogResponse{ responses = append(responses, model.StockLogResponse{
ID: log.ID, ID: log.ID,
MaterialID: log.MaterialID, MaterialID: log.MaterialID,
ChangeType: changeType, ChangeType: changeType,
ChangeQuantity: log.Quantity, ChangeQuantity: quantity,
BeforeQuantity: beforeQuantity, BeforeQuantity: beforeQuantity,
AfterQuantity: balance, AfterQuantity: balance,
ReferenceID: log.RelatedOrderID, ReferenceID: log.RelatedOrderID,
@ -82,15 +88,16 @@ func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse,
changeType = "inbound" changeType = "inbound"
} }
beforeQuantity := log.RunningBalance - log.Quantity quantity := decimal.NewFromInt(int64(log.Quantity))
beforeQuantity := log.RunningBalance.Sub(quantity)
if log.Type != "in" { if log.Type != "in" {
beforeQuantity = log.RunningBalance + log.Quantity beforeQuantity = log.RunningBalance.Add(quantity)
} }
// inbound=+quantity, outbound=-quantity // inbound=+quantity, outbound=-quantity
changeQuantity := log.Quantity changeQuantity := quantity
if log.Type != "in" { if log.Type != "in" {
changeQuantity = -log.Quantity changeQuantity = quantity.Neg()
} }
responses = append(responses, model.StockLogResponse{ responses = append(responses, model.StockLogResponse{
@ -118,3 +125,7 @@ func (s *StockService) MaterialExists(materialID int64) (bool, error) {
} }
return true, nil return true, nil
} }
func (s *StockService) GetInventoryBalanceDetails(materialID int64) ([]model.InventoryBalanceDetail, error) {
return s.stockRepo.GetInventoryBalanceDetails(materialID)
}

View File

@ -89,35 +89,7 @@ INSERT INTO material_types (organization_id, code, name) VALUES
(1, 'service', '服务'); (1, 'service', '服务');
INSERT INTO material_categories (organization_id, code, name) INSERT INTO material_categories (organization_id, code, name)
VALUES SELECT 1, category_code, category_name FROM materials_category;
(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) INSERT INTO units (organization_id, code, name)
SELECT 1, unit, unit FROM materials WHERE unit IS NOT NULL AND unit <> '' GROUP BY unit; SELECT 1, unit, unit FROM materials WHERE unit IS NOT NULL AND unit <> '' GROUP BY unit;