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

@ -34,7 +34,7 @@
当前系统具备物料、简单入库、简单出库、库存查询、物料申请、用户登录和日志页面,但与 v2.3 存在以下结构性差距:
| 当前实现 | v2.3 目标 | 处理阶段 |
| --------------------------------- | --------------------- | --------- |
| --------------------------------- | ------------------------------- | --------- |
| 用户只有一个 `admin/user` 枚举角色 | 用户与角色多对多,权限和数据范围分别控制 | 第一阶段 |
| 自制固定密钥 Token,权限依赖 Token 中的单角色 | 标准 JWT,按请求加载最新角色和数据范围 | 第一阶段 |
| 物料编码全局唯一 | 同一经营组织内唯一 | 第一阶段 |
@ -50,7 +50,7 @@
## 二、三次交付边界
| 阶段 | 交付主题 | 覆盖故事 | 可独立上线的业务结果 |
| ---- | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ |
| ---- | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| 第一阶段 | 组织、权限和主数据底座 | 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 | 入库、出库、调拨、盘点、预留、撤销过账、库存流水和预警形成完整闭环 |
| 第三阶段 | 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) {
config.headers.Authorization = `Bearer ${token}`
}
console.log('API请求:', config.method?.toUpperCase(), config.url)
return config
},
(error) => Promise.reject(error)
@ -38,6 +39,25 @@ request.interceptors.response.use(
localStorage.removeItem('wmms_user')
router.push('/login')
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 {
ElMessage.error(error.message || '网络错误')
}

View File

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

View File

@ -12,11 +12,5 @@ export const organizationsApi = {
},
listRoles() {
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 = {}) {
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>
<el-dialog v-model="visible" title="确认删除" width="400px">
<el-dialog v-model="visible" :title="title" width="400px">
<p>{{ content }}</p>
<template #footer>
<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>
</el-dialog>
</template>
@ -13,6 +13,14 @@ import { ref, watch } from 'vue'
const props = defineProps({
modelValue: Boolean,
title: {
type: String,
default: '确认删除'
},
confirmText: {
type: String,
default: '确认删除'
},
content: {
type: String,
default: '确定要删除此数据吗?此操作不可恢复。'

View File

@ -4,12 +4,14 @@
class="app-sidebar"
@select="handleSelect"
>
<el-menu-item
v-for="item in visibleMenuItems"
:key="item.path"
:index="item.path"
>
<span>{{ item.label }}</span>
<el-sub-menu v-for="section in navigation.sections" :key="section.key" :index="section.key">
<template #title>{{ section.label }}</template>
<el-menu-item v-for="item in section.items" :key="item.path" :index="item.path">
{{ item.label }}
</el-menu-item>
</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>
</template>
@ -17,6 +19,7 @@
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { getNavigationMenu } from '../../navigation/menu'
const props = defineProps({
activeMenu: String
@ -26,23 +29,7 @@ const emit = defineEmits(['menu-select'])
const authStore = useAuthStore()
const menuItems = [
{ 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
})
})
const navigation = computed(() => getNavigationMenu({ isAdmin: authStore.isAdmin }))
function handleSelect(path) {
emit('menu-select', path)
@ -52,5 +39,6 @@ function handleSelect(path) {
<style scoped>
.app-sidebar {
height: 100%;
overflow-y: auto;
}
</style>

View File

@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes = [
export const routes = [
{
path: '/login',
name: 'Login',
@ -18,6 +18,36 @@ const routes = [
component: () => import('../views/Materials.vue'),
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',
name: 'Inbound',

View File

@ -7,7 +7,8 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(JSON.parse(localStorage.getItem('wmms_user') || 'null'))
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) {
token.value = newToken
@ -33,7 +34,7 @@ export const useAuthStore = defineStore('auth', () => {
if (!token.value) return null
try {
const res = await authApi.getCurrentUser()
user.value = res.data
saveAuth(token.value, res.data)
return res.data
} catch {
clearAuth()
@ -46,7 +47,7 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
token, user, isLoggedIn, isAdmin,
token, user, isLoggedIn, isAdmin, hasRole,
saveAuth, clearAuth, login, fetchCurrentUser, logout
}
})

View File

@ -40,8 +40,13 @@ export const useInboundStore = defineStore('inbound', () => {
return res
}
async function fetchInboundDetail(id) {
const res = await inboundApi.getById(id)
return res.data || res
}
return {
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
}
async function deleteMaterial(id) {
const res = await materialsApi.delete(id)
async function obsoleteMaterial(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)
return res
}
return {
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) {
const res = await outboundApi.getById(id)
return res
return res.data || res
}
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 {
stockList, warningList, logs, loading,
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-group': false
}
config.global.renderStubDefaultSlot = true

View File

@ -29,12 +29,12 @@
{{ formatDate(row.created_at) }}
</template>
</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 }">
{{ formatDate(row.updated_at) }}
</template>
</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 }">
<el-button size="small" @click="showDetail(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
}
function showDetail(row) {
currentRow.value = row
async function showDetail(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
}
@ -255,4 +263,10 @@ function formatDate(dateStr) {
.mt-3 {
margin-top: 16px;
}
:deep(.pr-3) {
padding-right: 12px;
}
:deep(.border-left) {
border-left: 1px solid var(--el-table-border-color);
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="materials-page">
<div class="page-header">
<h2>物料管理</h2>
<h2>物料主数据</h2>
<el-button type="primary" @click="showAddModal">新增物料</el-button>
</div>
@ -24,39 +24,32 @@
:total="materialsStore.pagination.total"
@page-change="handlePageChange"
>
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="material_image" label="物料图片" width="80">
<el-table-column label="大类类型" min-width="105">
<template #default="{ row }">
<el-image
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>
{{ getMajorCategoryDisplay(row) }}
</template>
</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="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="warning_stock" label="预警库存" width="100" />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column
label="操作"
width="240"
fixed="right"
class-name="operation-column"
label-class-name="operation-column"
>
<template #default="{ row }">
<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="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>
</el-table-column>
</BaseTable>
@ -72,43 +65,20 @@
<el-form-item label="规格">
<el-input v-model="form.spec" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category" style="width: 100%">
<el-option label="标准件" value="A01" />
<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="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-form-item label="大类类型" required>
<el-select v-model="form.major_category_id" style="width: 100%" @change="syncMajorCategory">
<el-option v-for="category in majorCategories" :key="category.id" :label="`${category.code} ${category.name}`" :value="category.id" />
</el-select>
</el-form-item>
<el-form-item label="小类类型" required>
<el-select v-model="form.category_id" :disabled="!form.major_category_id" style="width: 100%" @change="syncCategory">
<el-option v-for="category in minorCategories" :key="category.id" :label="category.name" :value="category.id" />
</el-select>
</el-form-item>
<el-form-item label="单位">
<el-input v-model="form.unit" />
</el-form-item>
<el-form-item label="预警库存">
<el-input-number v-model="form.warning_stock" :min="0" />
<el-select v-model="form.base_unit_id" style="width: 100%" @change="syncUnit">
<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-select>
</el-form-item>
<el-form-item label="图片URL">
<el-input v-model="form.material_image" placeholder="图片URL" />
@ -127,12 +97,12 @@
<el-dialog v-model="detailVisible" title="物料详情" width="600px">
<el-descriptions :column="2" border v-if="detailRow">
<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.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.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="更新时间" :span="2">{{ formatDate(detailRow.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="物料图片" :span="2">
@ -150,6 +120,9 @@
<DeleteConfirm
v-model="deleteDialogVisible"
title="确认作废该物料?"
content="作废后该物料不能再用于新业务;已有业务记录仍会保留。"
confirm-text="确认作废"
@confirm="confirmDelete"
/>
@ -158,13 +131,16 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { computed, ref, reactive, onMounted } from 'vue'
import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import AlertTip from '../components/business/AlertTip.vue'
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 authStore = useAuthStore()
@ -182,6 +158,10 @@ const alertMessage = ref('')
const alertType = ref('success')
const imagePreview = ref('')
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) {
const file = e.target.files[0]
@ -190,19 +170,37 @@ function handleImageUpload(e) {
}
}
const form = reactive({
material_code: '',
name: '',
spec: '',
category: '',
unit: 'pcs',
warning_stock: 0,
material_image: ''
const form = reactive(createMaterialForm())
onMounted(async () => {
materialsStore.fetchMaterials()
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
categories.value = (categoryResponse.data || []).filter(item => item.status === 'active')
units.value = (unitResponse.data || []).filter(item => item.status === 'active')
})
onMounted(() => {
materialsStore.fetchMaterials()
})
function syncCategory(id) {
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() {
materialsStore.fetchMaterials(1, searchKeyword.value)
@ -215,15 +213,7 @@ function handlePageChange(page) {
function showAddModal() {
editingId.value = null
dialogTitle.value = '新增物料'
Object.assign(form, {
material_code: '',
name: '',
spec: '',
category: '',
unit: 'pcs',
warning_stock: 0,
material_image: ''
})
Object.assign(form, createMaterialForm())
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true
@ -237,24 +227,20 @@ function showDetail(row) {
function showEditModal(row) {
editingId.value = row.id
dialogTitle.value = '编辑物料'
Object.assign(form, {
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 || ''
})
Object.assign(form, createMaterialForm(row, categories.value))
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true
}
async function handleSave() {
if (!form.major_category_id || !form.category_id) {
ElMessage.warning('请选择大类类型和小类类型')
return
}
saveLoading.value = true
try {
const formData = { ...form }
const formData = toMaterialPayload(form)
const file = imageInput.value?.files[0]
if (file) {
const uploadForm = new FormData()
@ -290,8 +276,17 @@ function handleDelete(id) {
async function confirmDelete() {
try {
await materialsStore.deleteMaterial(deleteId.value)
ElMessage.success('物料删除成功')
await materialsStore.obsoleteMaterial(deleteId.value)
ElMessage.success('物料已作废')
} catch (e) {
// 错误已由 API 层处理
}
}
async function handleRestore(id) {
try {
await materialsStore.restoreMaterial(id)
ElMessage.success('物料已恢复')
} catch (e) {
// 错误已由 API 层处理
}
@ -338,4 +333,7 @@ function formatDate(dateStr) {
.search-native-input:focus {
border-color: #409eff;
}
:deep(.el-table__cell.operation-column) {
border-left: 1px solid var(--el-table-border-color) !important;
}
</style>

View File

@ -23,12 +23,12 @@
<el-table-column prop="receiver" label="接收方" />
<el-table-column prop="operator" 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 }">
{{ formatDate(row.created_at) }}
</template>
</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 }">
<el-button size="small" @click="showDetail(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 DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus'
import { isLowStock as isQuantityLowStock } from '../utils/quantity'
const outboundStore = useOutboundStore()
const materialsStore = useMaterialsStore()
@ -227,8 +228,7 @@ function isLowStock(materialId) {
if (!materialId) return false
const stock = stockStore.stockList.find(s => s.material_id === materialId)
if (!stock) return false
const material = materialsStore.materials.find(m => m.id === materialId)
return material && stock.current_stock <= material.warning_stock
return isQuantityLowStock(stock)
}
async function handleSave() {
@ -269,8 +269,15 @@ async function confirmDelete() {
ElMessage.success('删除成功')
}
function showDetail(row) {
currentRow.value = row
async function showDetail(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
}
@ -310,4 +317,10 @@ function formatDate(dateStr) {
.mt-3 {
margin-top: 16px;
}
:deep(.pr-3) {
padding-right: 12px;
}
:deep(.border-left) {
border-left: 1px solid var(--el-table-border-color);
}
</style>

View File

@ -1,5 +1,9 @@
<template>
<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-tab-pane label="库存查询" name="list">
<div class="search-bar">
@ -10,21 +14,7 @@
style="width: 220px"
/>
<el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px">
<el-option label="标准件" value="A01" />
<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-option v-for="cat in majorCategories" :key="cat.id" :label="`${cat.code} ${cat.name}`" :value="cat.code" />
</el-select>
<el-button @click="handleSearch">查询</el-button>
</div>
@ -36,25 +26,38 @@
:total="stockStore.stockPage.total"
@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="name" label="名称" />
<el-table-column prop="spec" label="规格" />
<el-table-column label="分类">
<el-table-column label="大类类型">
<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>
</el-table-column>
<el-table-column prop="unit" label="单位" />
<el-table-column prop="current_stock" label="库存数量" />
<el-table-column prop="warning_stock" label="预警值" />
<el-table-column label="状态">
<el-table-column label="状态" :class-name="'pr-3'">
<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>
</template>
</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>
</el-tab-pane>
@ -68,24 +71,23 @@
@page-change="handleWarningPageChange"
>
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_id" label="物料ID" />
<el-table-column prop="material_name" label="物料名称" />
<el-table-column prop="name" label="物料名称" />
<el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="quantity" label="当前库存">
<el-table-column prop="current_stock" label="当前库存">
<template #default="{ row }">
<span style="color: red">{{ row.quantity }}</span>
<span style="color: red">{{ row.current_stock }}</span>
</template>
</el-table-column>
<el-table-column prop="warning_stock" label="预警值" />
<el-table-column label="缺货量">
<template #default="{ row }">
{{ Math.max(0, row.warning_stock - row.quantity) }}
{{ Math.max(0, row.warning_stock - row.current_stock) }}
</template>
</el-table-column>
</BaseTable>
</el-tab-pane>
<el-tab-pane label="库存日志" name="logs">
<el-tab-pane label="库存流水" name="logs">
<div class="logs-filter">
<el-select
v-model="logMaterialId"
@ -139,21 +141,79 @@
</BaseTable>
</el-tab-pane>
</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>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, watch, computed } from 'vue'
import { useStockStore } from '../stores/stock'
import { stockApi } from '../api/stock'
import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.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 materialsStore = useMaterialsStore()
const authStore = useAuthStore()
const activeTab = ref('list')
const warehouseDialogVisible = ref(false)
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(activeTab, (newTab) => {
@ -164,30 +224,31 @@ watch(activeTab, (newTab) => {
const logMaterialSearchText = ref('')
const searchKeyword = ref('')
const categoryFilter = ref('')
const warningDialogVisible = ref(false)
const warningSaving = ref(false)
const warningMaterial = ref(null)
const warningValue = ref(0)
const categoryMap = {
'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(() => {
onMounted(async () => {
stockStore.fetchStock()
stockStore.fetchStockWarning()
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() {
stockStore.fetchStock(1, searchKeyword.value, categoryFilter.value)
}
@ -196,6 +257,37 @@ function handlePageChange(page) {
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) {
logMaterialSearchText.value = val
}
@ -241,6 +333,15 @@ function getReferenceTypeName(type) {
.stock-page {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-header h2 {
margin: 0;
}
.logs-filter {
display: flex;
gap: 10px;
@ -251,4 +352,21 @@ function getReferenceTypeName(type) {
gap: 10px;
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>

View File

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

View File

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

View File

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

View File

@ -4,10 +4,13 @@ import (
"database/sql"
"errors"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/middleware"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response"
)
@ -20,6 +23,10 @@ type Handler struct {
logSvc *service.MaterialLogService
authSvc *service.AuthService
appSvc *service.ApplicationService
organizationSvc *service.OrganizationService
masterDataSvc *service.MasterDataService
partnerSvc *service.PartnerService
warehouseSvc *service.WarehouseService
}
func NewHandler() *Handler {
@ -31,6 +38,10 @@ func NewHandler() *Handler {
logSvc: service.NewMaterialLogService(),
authSvc: service.NewAuthService(),
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.POST("", h.CreateMaterial)
materials.PUT("/:id", h.UpdateMaterial)
materials.DELETE("/:id", h.DeleteMaterial)
materials.POST("/:id/obsolete", h.ObsoleteMaterial)
}
inbound := api.Group("/inbound")
@ -98,6 +109,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
stock.GET("/warning", h.GetWarningStock)
stock.GET("/logs", h.GetAllStockLogs)
stock.GET("/logs/:materialId", h.GetStockLogs)
stock.PUT("/:materialId/warning", h.UpdateWarningStock)
stock.GET("/:materialId", h.GetStock)
}
@ -111,6 +123,69 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
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)
applications := api.Group("/applications")
applications.Use(middleware.AuthMiddleware())
@ -191,6 +266,11 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
orgID, ok := organizationID(c)
if !ok {
return
}
req.OrganizationID = orgID
operator := middleware.GetCurrentUsername(c)
if operator == "" {
@ -259,6 +339,27 @@ func (h *Handler) DeleteMaterial(c *gin.Context) {
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
func (h *Handler) ListInbound(c *gin.Context) {
@ -549,9 +650,8 @@ func (h *Handler) ListStock(c *gin.Context) {
}
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 {
response.InternalError(c, err.Error())
return
@ -609,6 +709,30 @@ func (h *Handler) GetWarningStock(c *gin.Context) {
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) {
materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64)
if err != nil {
@ -771,8 +895,6 @@ func (h *Handler) Logout(c *gin.Context) {
func (h *Handler) GetCurrentUser(c *gin.Context) {
userID := middleware.GetCurrentUserID(c)
username := middleware.GetCurrentUsername(c)
role := middleware.GetCurrentRole(c)
user, err := h.authSvc.GetUserByID(userID)
if err != nil {
@ -780,12 +902,7 @@ func (h *Handler) GetCurrentUser(c *gin.Context) {
return
}
// Return user info (password already hidden with json:"-" tag)
response.Success(c, gin.H{
"id": user.ID,
"username": username,
"role": role,
})
response.Success(c, user)
}
// User management handlers (Admin only)
@ -823,7 +940,7 @@ func (h *Handler) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" 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"`
}
@ -832,13 +949,12 @@ func (h *Handler) CreateUser(c *gin.Context) {
return
}
if req.Role != "admin" && req.Role != "user" {
response.BadRequest(c, "invalid role, must be admin or user")
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs)
if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) {
response.BadRequest(c, err.Error())
return
}
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Role, req.Description)
if err != nil {
response.InternalError(c, err.Error())
return
}
@ -854,9 +970,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
Description string `json:"description"`
}
@ -865,13 +980,12 @@ func (h *Handler) UpdateUser(c *gin.Context) {
return
}
if req.Role != "admin" && req.Role != "user" {
response.BadRequest(c, "invalid role, must be admin or user")
err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password)
if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) || errors.Is(err, repository.ErrLastSuperAdmin) {
response.BadRequest(c, err.Error())
return
}
err = h.authSvc.UpdateUser(id, req.Username, req.Role, req.Description, req.Password)
if err != nil {
response.InternalError(c, err.Error())
return
}
@ -894,6 +1008,10 @@ func (h *Handler) DeleteUser(c *gin.Context) {
}
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())
return
}
@ -950,9 +1068,7 @@ func (h *Handler) ListMyApplications(c *gin.Context) {
}
func (h *Handler) ListApplications(c *gin.Context) {
// Check if admin
role := middleware.GetCurrentRole(c)
if role != "admin" {
if !middleware.IsSuperAdmin(c) {
response.Unauthorized(c, "admin only")
return
}
@ -1010,9 +1126,7 @@ func (h *Handler) ApproveApplication(c *gin.Context) {
return
}
// Check if admin
role := middleware.GetCurrentRole(c)
if role != "admin" {
if !middleware.IsSuperAdmin(c) {
response.Unauthorized(c, "admin only")
return
}
@ -1048,9 +1162,7 @@ func (h *Handler) RejectApplication(c *gin.Context) {
return
}
// Check if admin
role := middleware.GetCurrentRole(c)
if role != "admin" {
if !middleware.IsSuperAdmin(c) {
response.Unauthorized(c, "admin only")
return
}

View File

@ -13,7 +13,7 @@ const (
BearerPrefix = "Bearer "
UserIDKey = "user_id"
UsernameKey = "username"
RoleKey = "role"
RolesKey = "roles"
)
// AuthMiddleware validates JWT tokens
@ -46,7 +46,7 @@ func AuthMiddleware() gin.HandlerFunc {
// Set user info in context
c.Set(UserIDKey, token.UserID)
c.Set(UsernameKey, token.Username)
c.Set(RoleKey, token.Role)
c.Set(RolesKey, token.Roles)
c.Next()
}
@ -55,14 +55,14 @@ func AuthMiddleware() gin.HandlerFunc {
// AdminOnly middleware restricts access to admin role only
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get(RoleKey)
roles, exists := c.Get(RolesKey)
if !exists {
response.Unauthorized(c, "unauthorized")
c.Abort()
return
}
if role != "admin" {
if !hasRole(roles, "super_admin") {
response.Forbidden(c, "admin access required")
c.Abort()
return
@ -88,12 +88,32 @@ func GetCurrentUsername(c *gin.Context) string {
return ""
}
// GetCurrentRole returns the current user's role from context
func GetCurrentRole(c *gin.Context) string {
if role, exists := c.Get(RoleKey); exists {
return role.(string)
// GetCurrentRoles returns the authenticated role-code snapshot. This is the
// extension point for replacing token roles with a request-time principal.
func GetCurrentRoles(c *gin.Context) []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
@ -122,7 +142,7 @@ func OptionalAuth() gin.HandlerFunc {
c.Set(UserIDKey, token.UserID)
c.Set(UsernameKey, token.Username)
c.Set(RoleKey, token.Role)
c.Set(RolesKey, token.Roles)
c.Next()
}

View File

@ -1,18 +1,25 @@
package model
import "time"
import (
"time"
"github.com/shopspring/decimal"
)
// Material represents a material in the warehouse
type Material struct {
ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"`
MaterialCode string `json:"material_code"`
Name string `json:"name"`
MaterialImage *string `json:"material_image,omitempty"`
Spec string `json:"spec,omitempty"`
Category string `json:"category,omitempty"`
ManufacturerModel *string `json:"manufacturer_model,omitempty"`
CategoryID *int64 `json:"category_id,omitempty"`
BaseUnitID *int64 `json:"base_unit_id,omitempty"`
Unit string `json:"unit,omitempty"`
WarningStock int `json:"warning_stock"`
CurrentStock int `json:"current_stock,omitempty"`
Status string `json:"status"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -98,7 +105,7 @@ type StockLog struct {
RelatedOrderType string `json:"related_order_type"`
RelatedOrderID int64 `json:"related_order_id"`
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
@ -107,10 +114,28 @@ type Stock struct {
MaterialCode string `json:"material_code"`
Name string `json:"name"`
Spec string `json:"spec,omitempty"`
Category string `json:"category,omitempty"`
Unit string `json:"unit,omitempty"`
CurrentStock int `json:"current_stock"`
WarningStock int `json:"warning_stock"`
MajorCategory string `json:"major_category"`
MinorCategory string `json:"minor_category"`
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
@ -118,9 +143,9 @@ type StockLogResponse struct {
ID int64 `json:"id"`
MaterialID int64 `json:"material_id"`
ChangeType string `json:"change_type"`
ChangeQuantity int `json:"change_quantity"`
BeforeQuantity int `json:"before_quantity"`
AfterQuantity int `json:"after_quantity"`
ChangeQuantity decimal.Decimal `json:"change_quantity"`
BeforeQuantity decimal.Decimal `json:"before_quantity"`
AfterQuantity decimal.Decimal `json:"after_quantity"`
ReferenceID int64 `json:"reference_id"`
ReferenceType string `json:"reference_type"`
CreatedAt time.Time `json:"created_at"`
@ -150,24 +175,30 @@ type OutboundRecord struct {
// CreateMaterialRequest represents the request to create a material
type CreateMaterialRequest struct {
OrganizationID int64 `json:"organization_id"`
MaterialCode string `json:"material_code" binding:"required"`
Name string `json:"name" binding:"required"`
MaterialImage string `json:"material_image"`
MaterialImage *string `json:"material_image"`
Spec string `json:"spec"`
Category string `json:"category"`
ManufacturerModel *string `json:"manufacturer_model"`
CategoryID *int64 `json:"category_id"`
BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
WarningStock int `json:"warning_stock"`
WarningStock decimal.Decimal `json:"warning_stock"`
}
// UpdateMaterialRequest represents the request to update a material
type UpdateMaterialRequest struct {
MaterialCode string `json:"material_code"`
Name string `json:"name"`
MaterialImage string `json:"material_image"`
MaterialImage *string `json:"material_image"`
Spec string `json:"spec"`
Category string `json:"category"`
ManufacturerModel *string `json:"manufacturer_model"`
CategoryID *int64 `json:"category_id"`
BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
WarningStock int `json:"warning_stock"`
Status string `json:"status"`
WarningStock decimal.Decimal `json:"warning_stock"`
}
// CreateInboundRequest represents the request to create an inbound order
@ -228,6 +259,7 @@ type User struct {
Username string `json:"username"`
Password string `json:"-"`
Role string `json:"role"`
Roles []Role `json:"roles"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -194,32 +194,6 @@ func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApp
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 {
query := `INSERT INTO outbound_orders (outbound_date, handler, receiver, status) VALUES (NOW(), ?, ?, ?)`
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 {
query := `INSERT INTO materials (material_code, name, material_image, spec, category, unit, warning_stock) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := database.DB.Exec(query, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, m.Category, m.Unit, m.WarningStock)
query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model,
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 strings.Contains(err.Error(), "Duplicate entry") {
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 {
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
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 {
return err
}
if materialImage.Valid {
m.MaterialImage = &materialImage.String
}
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
return nil
}
func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) {
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
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 {
return nil, err
}
if materialImage.Valid {
m.MaterialImage = &materialImage.String
}
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
return m, nil
}
@ -73,10 +92,6 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern, searchPattern)
}
if category != "" {
countQuery += ` AND category = ?`
countArgs = append(countArgs, category)
}
var total int64
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
}
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{}
if search != "" {
@ -92,10 +109,6 @@ func (r *MaterialRepo) List(search, category string, limit, offset int) ([]model
searchPattern := "%" + search + "%"
args = append(args, searchPattern, searchPattern)
}
if category != "" {
query += ` AND category = ?`
args = append(args, category)
}
query += ` ORDER BY id DESC 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() {
var m model.Material
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 {
return nil, 0, err
}
if materialImage.Valid {
m.MaterialImage = &materialImage.String
}
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
materials = append(materials, m)
}
return materials, total, nil
@ -125,35 +144,39 @@ func (r *MaterialRepo) Update(id int64, req *model.UpdateMaterialRequest) error
var sets []string
var args []interface{}
if req.MaterialCode != "" {
sets = append(sets, "material_code = ?")
args = append(args, req.MaterialCode)
}
if req.Name != "" {
sets = append(sets, "name = ?")
args = append(args, req.Name)
}
if req.MaterialImage != "" {
if req.MaterialImage != nil && *req.MaterialImage != "" {
sets = append(sets, "material_image = ?")
args = append(args, req.MaterialImage)
args = append(args, *req.MaterialImage)
}
if req.Spec != "" {
sets = append(sets, "spec = ?")
args = append(args, req.Spec)
}
if req.Category != "" {
sets = append(sets, "category = ?")
args = append(args, req.Category)
if req.ManufacturerModel != nil && *req.ManufacturerModel != "" {
sets = append(sets, "manufacturer_model = ?")
args = append(args, *req.ManufacturerModel)
}
if req.CategoryID != nil {
sets = append(sets, "category_id = ?")
args = append(args, req.CategoryID)
}
if req.Unit != "" {
sets = append(sets, "unit = ?")
args = append(args, req.Unit)
}
if req.WarningStock > 0 {
sets = append(sets, "warning_stock = ?")
args = append(args, req.WarningStock)
if req.BaseUnitID != nil {
sets = append(sets, "base_unit_id = ?")
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 {
return nil
}
@ -190,40 +213,26 @@ func (r *MaterialRepo) Delete(id int64) error {
return err
}
func (r *MaterialRepo) UpdateStock(id int64, delta int) error {
query := `UPDATE materials SET current_stock = current_stock + ? WHERE id = ?`
result, err := database.DB.Exec(query, delta, id)
if err != nil {
return err
}
affected, err := result.RowsAffected()
func (r *MaterialRepo) Obsolete(id int64) error {
var nonZero int
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 {
return err
}
if nonZero > 0 {
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 {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
return fmt.Errorf("物料不存在")
}
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
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)
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"}).
AddRow(7, "MAT-007", "轴承", nil, "6205", "A02", "件", now, now),
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, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "active", 2, now, now),
)
var material model.Material

View File

@ -2,7 +2,9 @@ package repository
import (
"database/sql"
"fmt"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
@ -13,19 +15,15 @@ func NewStockRepo() *StockRepo {
return &StockRepo{}
}
func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1`
func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1`
var countArgs []interface{}
if search != "" {
countQuery += ` AND (material_code LIKE ? OR name LIKE ?)`
countQuery += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern, searchPattern)
}
if category != "" {
countQuery += ` AND category = ?`
countArgs = append(countArgs, category)
}
var total int64
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
}
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{}
if search != "" {
query += ` AND (material_code LIKE ? OR name LIKE ?)`
query += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
searchPattern := "%" + search + "%"
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)
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
for rows.Next() {
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 {
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) {
s := &model.Stock{}
query := `SELECT id, material_code, name, spec, category, unit, current_stock, warning_stock FROM materials WHERE id = ?`
err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Category, &s.Unit, &s.CurrentStock, &s.WarningStock)
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 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 {
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) {
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
err := database.DB.QueryRow(countQuery).Scan(&total)
if err != nil {
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)
if err != nil {
return nil, 0, err
@ -95,7 +127,7 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
var stocks []model.Stock
for rows.Next() {
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 {
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.
// 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 {
var delta int
database.DB.QueryRow(`
SELECT COALESCE(SUM(CASE WHEN type='in' THEN quantity ELSE -quantity END), 0)
FROM stock_logs WHERE material_id = ?`, mid).Scan(&delta)
var currentStock int
database.DB.QueryRow(`SELECT current_stock FROM materials WHERE id = ?`, mid).Scan(&currentStock)
initialBalances[mid] = currentStock - delta
var currentStock decimal.Decimal
if err := database.DB.QueryRow(`SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`, mid).Scan(&currentStock); err != nil {
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).
@ -247,9 +281,9 @@ func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog,
r := &pageRows[i]
bal := initialBalances[r.log.MaterialID]
if r.log.Type == "in" {
bal += r.log.Quantity
bal = bal.Add(decimal.NewFromInt(int64(r.log.Quantity)))
} else {
bal -= r.log.Quantity
bal = bal.Sub(decimal.NewFromInt(int64(r.log.Quantity)))
}
r.log.RunningBalance = bal
initialBalances[r.log.MaterialID] = bal
@ -262,6 +296,74 @@ func (r *StockRepo) GetAllLogsWithBalance(limit, offset int) ([]model.StockLog,
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 {
query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?`
_, 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)
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"
"github.com/DATA-DOG/go-sqlmock"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/pkg/database"
)
const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.category, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0)
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),
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.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) {
db, mock, err := sqlmock.New()
@ -29,22 +33,50 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
database.DB = db
mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "category", "unit", "on_hand", "minimum_available"}).
AddRow(9, "MAT-009", "电机", "80W", "A03", "台", 12, 3),
sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "major_category", "minor_category"}).
AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", "B01", "CAT-SUB-027"),
)
stock, err := NewStockRepo().GetByMaterialID(9)
if err != nil {
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)
}
if stock.MajorCategory != "B01" || stock.MinorCategory != "CAT-SUB-027" {
t.Fatalf("unexpected stock categories: %+v", stock)
}
if err := mock.ExpectationsWereMet(); err != nil {
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) {
db, mock, err := sqlmock.New()
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'
WHERE o.code = 'DEFAULT'
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)
}
if err := mock.ExpectationsWereMet(); err != nil {
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
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
"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{}
func NewUserRepository() *UserRepository {
return &UserRepository{}
}
func NewUserRepository() *UserRepository { return &UserRepository{} }
func (r *UserRepository) GetByUsername(username string) (*model.User, error) {
query := `SELECT id, username, password, role, description, created_at FROM users WHERE username = ?`
var user model.User
err := database.DB.QueryRow(query, username).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
user := &model.User{}
err := database.DB.QueryRow(`SELECT id, username, password, description, created_at FROM users WHERE username = ?`, username).Scan(
&user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt,
)
if err != nil {
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) {
query := `SELECT id, username, password, role, description, created_at FROM users WHERE id = ?`
var user model.User
err := database.DB.QueryRow(query, id).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
user := &model.User{}
err := database.DB.QueryRow(`SELECT id, username, password, description, created_at FROM users WHERE id = ?`, id).Scan(
&user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt,
)
if err != nil {
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 {
query := `INSERT INTO users (username, password, role, description) VALUES (?, ?, ?, ?)`
result, err := database.DB.Exec(query, user.Username, user.Password, user.Role, user.Description)
func (r *UserRepository) Create(user *model.User, roleIDs []int64) error {
tx, err := database.DB.Begin()
if err != nil {
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 {
return err
}
user.ID = id
return nil
user.ID, err = result.LastInsertId()
if err != 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) {
@ -70,21 +74,18 @@ func (r *UserRepository) List(search string, limit, offset int) ([]model.User, i
var countArgs []interface{}
if search != "" {
countQuery += ` WHERE username LIKE ?`
searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern)
countArgs = append(countArgs, "%"+search+"%")
}
var total int64
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
if err != nil {
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
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{}
if search != "" {
query += ` WHERE username LIKE ?`
searchPattern := "%" + search + "%"
args = append(args, searchPattern)
args = append(args, "%"+search+"%")
}
query += ` ORDER BY id 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()
var users []model.User
users := make([]model.User, 0)
for rows.Next() {
var user model.User
err := rows.Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&user.Description,
&user.CreatedAt,
)
if err != nil {
if err := rows.Scan(&user.ID, &user.Username, &user.Password, &user.Description, &user.CreatedAt); err != nil {
return nil, 0, err
}
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
}
func (r *UserRepository) Update(user *model.User, roleIDs []int64) error {
return r.update(user, roleIDs, false)
}
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64) error {
return r.update(user, roleIDs, true)
}
func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePassword bool) error {
tx, err := database.DB.Begin()
if err != nil {
return err
}
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 {
query := `DELETE FROM users WHERE id = ?`
_, err := database.DB.Exec(query, id)
tx, err := database.DB.Begin()
if err != nil {
return err
}
func (r *UserRepository) Update(user *model.User) error {
query := `UPDATE users SET role = ?, description = ? WHERE id = ?`
_, err := database.DB.Exec(query, user.Role, user.Description, user.ID)
defer func() { _ = tx.Rollback() }()
if err := protectLastSuperAdminTx(tx, id, nil); err != nil {
return err
}
func (r *UserRepository) UpdateWithPassword(user *model.User) error {
query := `UPDATE users SET password = ?, role = ?, description = ? WHERE id = ?`
_, err := database.DB.Exec(query, user.Password, user.Role, user.Description, user.ID)
if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// UserExists checks if a user exists by username
func (r *UserRepository) UserExists(username string) (bool, error) {
query := `SELECT COUNT(*) FROM users WHERE username = ?`
func loadRolesForUsers(queryer interface {
Query(query string, args ...interface{}) (*sql.Rows, error)
}, users []*model.User) error {
if len(users) == 0 {
return nil
}
indexes := make(map[int64]*model.User, len(users))
args := make([]interface{}, 0, len(users))
for _, user := range users {
user.Roles = []model.Role{}
indexes[user.ID] = user
args = append(args, user.ID)
}
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()
}
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
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)
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
}
// CreateDefaultUsers creates default admin and user if no users exist
func CreateDefaultUsers() error {
repo := NewUserRepository()
exists, err := repo.UserExists("admin")
if err != nil {
return err
if count != len(roleIDs) {
return ErrInvalidRoles
}
if exists {
return nil
}
// Default passwords will be hashed in CreateUser
admin := &model.User{
Username: "admin",
Password: "", // Will be set by service
Role: "admin",
Description: "系统管理员",
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
}
// Use bcrypt to hash the default passwords
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)
if err != nil {
return err
}
admin.Password = string(adminHash)
if err := repo.Create(admin); err != nil {
admin := &model.User{Username: "admin", Password: string(adminHash), Description: "系统管理员"}
if err := repo.Create(admin, []int64{adminRoleID}); err != nil {
return err
}
userHash, err := bcrypt.GenerateFromPassword([]byte("user123"), bcrypt.DefaultCost)
if err != nil {
return err
}
user := &model.User{
Username: "user",
Password: string(userHash),
Role: "user",
Description: "普通用户",
user := &model.User{Username: "user", Password: string(userHash), Description: "普通用户"}
return repo.Create(user, []int64{warehouseRoleID})
}
return repo.Create(user)
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 id, err
}

View File

@ -1,6 +1,7 @@
package repository
import (
"errors"
"regexp"
"testing"
"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) {
db, mock, err := sqlmock.New()
if err != nil {

View File

@ -3,6 +3,7 @@ package service
import (
"errors"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
@ -15,11 +16,15 @@ var (
type ApplicationService struct {
appRepo *repository.ApplicationRepository
materialSvc *MaterialService
stockRepo *repository.StockRepo
}
func NewApplicationService() *ApplicationService {
return &ApplicationService{
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
var items []model.ApplicationItem
for _, item := range req.Items {
// Check material exists and get stock
_, err := s.appRepo.GetMaterialCurrentStock(item.MaterialID)
if err != nil {
exists, err := s.materialSvc.Exists(item.MaterialID)
if err != nil || !exists {
return nil, ErrMaterialNotFound
}
items = append(items, model.ApplicationItem{
@ -95,11 +99,11 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
// Check stock for all items
for _, item := range app.Items {
stock, err := s.appRepo.GetMaterialCurrentStock(item.MaterialID)
stock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil {
return ErrMaterialNotFound
}
if stock < item.Quantity {
if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
return ErrInsufficientStock
}
}
@ -133,13 +137,17 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
// Process each item within transaction
for _, appItem := range app.Items {
// 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 {
return ErrMaterialNotFound
}
if lockedStock.LessThan(decimal.NewFromInt(int64(appItem.Quantity))) {
return ErrInsufficientStock
}
// 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
}
@ -216,6 +224,6 @@ func (s *ApplicationService) Cancel(id int64, userID int64) error {
return s.appRepo.Cancel(id)
}
func (s *ApplicationService) GetMaterialStock(materialID int64) (int, error) {
return s.appRepo.GetMaterialCurrentStock(materialID)
func (s *ApplicationService) GetMaterialStock(materialID int64) (decimal.Decimal, error) {
return s.stockRepo.GetStock(materialID)
}

View File

@ -32,7 +32,7 @@ func NewAuthService() *AuthService {
type SimpleToken struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Roles []string `json:"roles"`
Exp int64 `json:"exp"`
}
@ -40,10 +40,16 @@ type SimpleToken struct {
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
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{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
Roles: roleCodes,
Exp: exp,
}
@ -152,7 +158,7 @@ func (s *AuthService) ListUsers(search string, limit, offset int) ([]model.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
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@ -162,25 +168,22 @@ func (s *AuthService) CreateUser(username, password, role, description string) (
user := &model.User{
Username: username,
Password: string(hashedPassword),
Role: role,
Description: description,
}
if err := s.userRepo.Create(user); err != nil {
if err := s.userRepo.Create(user, roleIDs); err != nil {
return nil, err
}
return user, nil
return s.userRepo.GetByID(user.ID)
}
// 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)
if err != nil {
return err
}
user.Role = role
user.Description = description
if newPassword != "" {
@ -189,10 +192,10 @@ func (s *AuthService) UpdateUser(id int64, username, role, description string, n
return err
}
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

View File

@ -3,6 +3,7 @@ package service
import (
"time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
@ -71,7 +72,7 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
item.MaterialCode = material.MaterialCode
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 {
return nil, err
}
@ -130,7 +131,7 @@ func (s *InboundService) Delete(id int64) error {
// Reverse stock for each item within transaction
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 {
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)
for _, item := range oldOrder.Items {
_, err = s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID)
_, err = s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil {
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 {
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
for _, item := range req.Items {
_, err = s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID)
_, err = s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil {
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 {
return nil, err
}

View File

@ -21,24 +21,34 @@ func NewMaterialService() *MaterialService {
}
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
organizationID := req.OrganizationID
if organizationID == 0 {
organizationID = 1
}
unit := req.Unit
if unit == "" {
unit = "pcs"
}
var materialImage *string
if req.MaterialImage != "" {
materialImage = &req.MaterialImage
if req.MaterialImage != nil {
materialImage = req.MaterialImage
}
m := &model.Material{
OrganizationID: organizationID,
MaterialCode: req.MaterialCode,
Name: req.Name,
MaterialImage: materialImage,
Spec: req.Spec,
Category: req.Category,
CategoryID: req.CategoryID,
BaseUnitID: req.BaseUnitID,
Unit: unit,
WarningStock: req.WarningStock,
Status: "active",
Version: 1,
}
if req.ManufacturerModel != nil {
m.ManufacturerModel = req.ManufacturerModel
}
err := s.repo.Create(m)
@ -54,6 +64,38 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
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) {
m := &model.Material{}
err := s.repo.GetByID(id, m)
@ -112,14 +154,6 @@ func (s *MaterialService) Delete(id int64, operator string) error {
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) {
m := &model.Material{}
err := s.repo.GetByID(id, m)
@ -131,13 +165,3 @@ func (s *MaterialService) Exists(id int64) (bool, error) {
}
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"
"time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
@ -42,11 +43,11 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.Outbo
return nil, ErrMaterialNotFound
}
currentStock, err := s.materialSvc.GetStock(item.MaterialID)
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil {
return nil, err
}
if currentStock < item.Quantity {
if currentStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
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
for i := range order.Items {
item := &order.Items[i]
lockedStock, err := s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID)
lockedStock, err := s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil {
return nil, err
}
if lockedStock < item.Quantity {
if lockedStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
err = ErrInsufficientStock
return nil, err
}
@ -97,7 +98,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest) (*model.Outbo
item.MaterialName = material.Name
// 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 {
return nil, err
}
@ -157,7 +158,7 @@ func (s *OutboundService) Delete(id int64) error {
// Reverse stock for each item (add back) within transaction
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 {
return err
}
@ -200,7 +201,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
return nil, ErrMaterialNotFound
}
currentStock, err := s.materialSvc.GetStock(item.MaterialID)
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
if err != nil {
return nil, err
}
@ -212,8 +213,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
break
}
}
effectiveStock := currentStock + oldQty
if effectiveStock < item.Quantity {
effectiveStock := currentStock.Add(decimal.NewFromInt(int64(oldQty)))
if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
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)
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 {
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
for _, item := range req.Items {
lockedStock, err := s.materialSvc.GetStockForUpdateTx(tx, item.MaterialID)
lockedStock, err := s.stockRepo.GetStockForUpdateTx(tx, item.MaterialID)
if err != nil {
return nil, err
}
@ -250,8 +251,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest) (*m
break
}
}
effectiveStock := lockedStock + oldQty
if effectiveStock < item.Quantity {
effectiveStock := lockedStock.Add(decimal.NewFromInt(int64(oldQty)))
if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
err = ErrInsufficientStock
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
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 {
return nil, err
}

View File

@ -3,6 +3,7 @@ package service
import (
"database/sql"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"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) {
return s.stockRepo.GetAllStock(search, category, limit, offset)
func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
return s.stockRepo.GetAllStock(search, limit, offset)
}
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)
}
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) {
logs, total, err := s.stockRepo.GetLogsByMaterialID(materialID, limit, offset)
if err != nil {
@ -36,12 +41,13 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
}
var responses []model.StockLogResponse
balance := 0
balance := decimal.Zero
for _, log := range logs {
quantity := decimal.NewFromInt(int64(log.Quantity))
if log.Type == "in" {
balance += log.Quantity
balance = balance.Add(quantity)
} else {
balance -= log.Quantity
balance = balance.Sub(quantity)
}
changeType := "outbound"
@ -49,16 +55,16 @@ func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int)
changeType = "inbound"
}
beforeQuantity := balance - log.Quantity
beforeQuantity := balance.Sub(quantity)
if log.Type != "in" {
beforeQuantity = balance + log.Quantity
beforeQuantity = balance.Add(quantity)
}
responses = append(responses, model.StockLogResponse{
ID: log.ID,
MaterialID: log.MaterialID,
ChangeType: changeType,
ChangeQuantity: log.Quantity,
ChangeQuantity: quantity,
BeforeQuantity: beforeQuantity,
AfterQuantity: balance,
ReferenceID: log.RelatedOrderID,
@ -82,15 +88,16 @@ func (s *StockService) GetAllLogs(limit, offset int) ([]model.StockLogResponse,
changeType = "inbound"
}
beforeQuantity := log.RunningBalance - log.Quantity
quantity := decimal.NewFromInt(int64(log.Quantity))
beforeQuantity := log.RunningBalance.Sub(quantity)
if log.Type != "in" {
beforeQuantity = log.RunningBalance + log.Quantity
beforeQuantity = log.RunningBalance.Add(quantity)
}
// inbound=+quantity, outbound=-quantity
changeQuantity := log.Quantity
changeQuantity := quantity
if log.Type != "in" {
changeQuantity = -log.Quantity
changeQuantity = quantity.Neg()
}
responses = append(responses, model.StockLogResponse{
@ -118,3 +125,7 @@ func (s *StockService) MaterialExists(materialID int64) (bool, error) {
}
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', '服务');
INSERT INTO material_categories (organization_id, code, name)
VALUES
(1, 'A01', '标准件'),
(1, 'A02', '成品件'),
(1, 'A03', '简易加工件'),
(1, 'A04', '精密加工件'),
(1, 'A05', '钣金件'),
(1, 'A06', '塑胶件'),
(1, 'A07', '其他'),
(1, 'B01', '成品件'),
(1, 'B02', '简易加工件'),
(1, 'B03', 'PCB硬板'),
(1, 'B04', 'FPC软板'),
(1, 'B05', '线束'),
(1, 'B06', '组装组件'),
(1, 'B07', '其他'),
(1, 'C01', '连接耗材'),
(1, 'C02', '密封耗材'),
(1, 'C03', '其他'),
(1, 'D01', '自研程序固件'),
(1, 'D02', '外购软件授权'),
(1, 'D03', '授权介质'),
(1, 'D04', '其他'),
(1, 'E01', '成品'),
(1, 'E02', '半成品'),
(1, 'F01', '资料文档'),
(1, 'F02', '标识标牌'),
(1, 'F03', '包装物料'),
(1, 'F04', '随机工具配件'),
(1, 'F05', '其他');
SELECT 1, category_code, category_name FROM materials_category;
INSERT INTO units (organization_id, code, name)
SELECT 1, unit, unit FROM materials WHERE unit IS NOT NULL AND unit <> '' GROUP BY unit;