restore lost frontend and backend changes
This commit is contained in:
parent
5a0376f318
commit
1cc3584f1a
|
|
@ -33,27 +33,27 @@
|
|||
|
||||
当前系统具备物料、简单入库、简单出库、库存查询、物料申请、用户登录和日志页面,但与 v2.3 存在以下结构性差距:
|
||||
|
||||
| 当前实现 | v2.3 目标 | 处理阶段 |
|
||||
| --------------------------------- | --------------------- | --------- |
|
||||
| 用户只有一个 `admin/user` 枚举角色 | 用户与角色多对多,权限和数据范围分别控制 | 第一阶段 |
|
||||
| 自制固定密钥 Token,权限依赖 Token 中的单角色 | 标准 JWT,按请求加载最新角色和数据范围 | 第一阶段 |
|
||||
| 物料编码全局唯一 | 同一经营组织内唯一 | 第一阶段 |
|
||||
| 分类和单位为自由文本 | 分类、物料类型、单位及换算为受控主数据 | 第一阶段 |
|
||||
| 没有业务伙伴、组织、仓库和库位实体 | 完整主数据及停用规则 | 第一阶段 |
|
||||
| 库存直接保存在 `materials.current_stock` | 第一阶段拆分独立库存余额,第二阶段启用不可变库存流水和过账内核 | 第一、二阶段 |
|
||||
| 创建或删除入出库单直接增减库存 | 草稿、提交、过账、撤销过账状态机 | 第二阶段 |
|
||||
| 没有调拨、盘点、预留和通知闭环 | 完整仓储执行闭环 | 第二阶段 |
|
||||
| 没有 BOM 和制造订单 | BOM 版本、展开、校验和制造执行 | 第三阶段 |
|
||||
| 没有 Excel 任务和 SSO | 受控批量任务及 OIDC SSO | 第三阶段 |
|
||||
| 没有自动化测试 | 后端单元/集成测试与前端组件测试 | 第一阶段起持续建设 |
|
||||
| 当前实现 | v2.3 目标 | 处理阶段 |
|
||||
| --------------------------------- | ------------------------------- | --------- |
|
||||
| 用户只有一个 `admin/user` 枚举角色 | 用户与角色多对多,权限和数据范围分别控制 | 第一阶段 |
|
||||
| 自制固定密钥 Token,权限依赖 Token 中的单角色 | 标准 JWT,按请求加载最新角色和数据范围 | 第一阶段 |
|
||||
| 物料编码全局唯一 | 同一经营组织内唯一 | 第一阶段 |
|
||||
| 分类和单位为自由文本 | 分类、物料类型、单位及换算为受控主数据 | 第一阶段 |
|
||||
| 没有业务伙伴、组织、仓库和库位实体 | 完整主数据及停用规则 | 第一阶段 |
|
||||
| 库存直接保存在 `materials.current_stock` | 第一阶段拆分独立库存余额,第二阶段启用不可变库存流水和过账内核 | 第一、二阶段 |
|
||||
| 创建或删除入出库单直接增减库存 | 草稿、提交、过账、撤销过账状态机 | 第二阶段 |
|
||||
| 没有调拨、盘点、预留和通知闭环 | 完整仓储执行闭环 | 第二阶段 |
|
||||
| 没有 BOM 和制造订单 | BOM 版本、展开、校验和制造执行 | 第三阶段 |
|
||||
| 没有 Excel 任务和 SSO | 受控批量任务及 OIDC SSO | 第三阶段 |
|
||||
| 没有自动化测试 | 后端单元/集成测试与前端组件测试 | 第一阶段起持续建设 |
|
||||
|
||||
## 二、三次交付边界
|
||||
|
||||
| 阶段 | 交付主题 | 覆盖故事 | 可独立上线的业务结果 |
|
||||
| ---- | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ |
|
||||
| 阶段 | 交付主题 | 覆盖故事 | 可独立上线的业务结果 |
|
||||
| ---- | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| 第一阶段 | 组织、权限和主数据底座 | ORG-001、CODE-001~004、MD-001~008、COM-001~005、WH-001~003、STOCK-003、AUTH-001、AUTH-003~006、AUD-001 | 用户可按多角色登录,只能访问授权组织/仓库;物料主数据与库存余额分表;可维护编码、物料、业务伙伴、仓库和库位 |
|
||||
| 第二阶段 | 仓储业务闭环 | STOCK-001~002、STOCK-004~009、NOTIFY-001 | 入库、出库、调拨、盘点、预留、撤销过账、库存流水和预警形成完整闭环 |
|
||||
| 第三阶段 | BOM、制造、批量和 SSO | BOM-001~010、BAT-001~004、AUTH-002 | 可维护受控 BOM,分批领料和完工;支持受控 Excel 任务和 OIDC 单点登录 |
|
||||
| 第二阶段 | 仓储业务闭环 | STOCK-001~002、STOCK-004~009、NOTIFY-001 | 入库、出库、调拨、盘点、预留、撤销过账、库存流水和预警形成完整闭环 |
|
||||
| 第三阶段 | BOM、制造、批量和 SSO | BOM-001~010、BAT-001~004、AUTH-002 | 可维护受控 BOM,分批领料和完工;支持受控 Excel 任务和 OIDC 单点登录 |
|
||||
|
||||
### 阶段依赖
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '网络错误')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '确定要删除此数据吗?此操作不可恢复。'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ config.global.stubs = {
|
|||
transition: false,
|
||||
'transition-group': false
|
||||
}
|
||||
config.global.renderStubDefaultSlot = true
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,33 +4,44 @@ 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"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
materialSvc *service.MaterialService
|
||||
inboundSvc *service.InboundService
|
||||
outboundSvc *service.OutboundService
|
||||
stockSvc *service.StockService
|
||||
logSvc *service.MaterialLogService
|
||||
authSvc *service.AuthService
|
||||
appSvc *service.ApplicationService
|
||||
materialSvc *service.MaterialService
|
||||
inboundSvc *service.InboundService
|
||||
outboundSvc *service.OutboundService
|
||||
stockSvc *service.StockService
|
||||
logSvc *service.MaterialLogService
|
||||
authSvc *service.AuthService
|
||||
appSvc *service.ApplicationService
|
||||
organizationSvc *service.OrganizationService
|
||||
masterDataSvc *service.MasterDataService
|
||||
partnerSvc *service.PartnerService
|
||||
warehouseSvc *service.WarehouseService
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
materialSvc: service.NewMaterialService(),
|
||||
inboundSvc: service.NewInboundService(),
|
||||
outboundSvc: service.NewOutboundService(),
|
||||
stockSvc: service.NewStockService(),
|
||||
logSvc: service.NewMaterialLogService(),
|
||||
authSvc: service.NewAuthService(),
|
||||
appSvc: service.NewApplicationService(),
|
||||
materialSvc: service.NewMaterialService(),
|
||||
inboundSvc: service.NewInboundService(),
|
||||
outboundSvc: service.NewOutboundService(),
|
||||
stockSvc: service.NewStockService(),
|
||||
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())
|
||||
|
|
@ -118,9 +193,9 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|||
applications.POST("", h.CreateApplication)
|
||||
applications.GET("/my", h.ListMyApplications)
|
||||
applications.POST("/:id/cancel", h.CancelApplication)
|
||||
applications.GET("", h.ListApplications) // admin only
|
||||
applications.POST("/:id/approve", h.ApproveApplication) // admin only
|
||||
applications.POST("/:id/reject", h.RejectApplication) // admin only
|
||||
applications.GET("", h.ListApplications) // admin only
|
||||
applications.POST("/:id/approve", h.ApproveApplication) // admin only
|
||||
applications.POST("/:id/reject", h.RejectApplication) // admin only
|
||||
}
|
||||
|
||||
// Current user route
|
||||
|
|
@ -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)
|
||||
|
|
@ -821,10 +938,10 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
|||
|
||||
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"`
|
||||
Description string `json:"description"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -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")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Role, req.Description)
|
||||
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
|
||||
}
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
@ -854,10 +970,9 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
|||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Description string `json:"description"`
|
||||
Password string `json:"password"`
|
||||
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -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")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.authSvc.UpdateUser(id, req.Username, req.Role, req.Description, req.Password)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import (
|
|||
|
||||
const (
|
||||
AuthorizationHeader = "Authorization"
|
||||
BearerPrefix = "Bearer "
|
||||
UserIDKey = "user_id"
|
||||
UsernameKey = "username"
|
||||
RoleKey = "role"
|
||||
BearerPrefix = "Bearer "
|
||||
UserIDKey = "user_id"
|
||||
UsernameKey = "username"
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,27 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Material represents a material in the warehouse
|
||||
type Material struct {
|
||||
ID int64 `json:"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"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
WarningStock int `json:"warning_stock"`
|
||||
CurrentStock int `json:"current_stock,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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"`
|
||||
ManufacturerModel *string `json:"manufacturer_model,omitempty"`
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
BaseUnitID *int64 `json:"base_unit_id,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tag represents a tag associated with a material
|
||||
|
|
@ -27,33 +34,33 @@ type Tag struct {
|
|||
|
||||
// ApplyOrder represents a material application/requisition order
|
||||
type ApplyOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
Applicant string `json:"applicant"`
|
||||
ApplyDate time.Time `json:"apply_date"`
|
||||
Status string `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Applicant string `json:"applicant"`
|
||||
ApplyDate time.Time `json:"apply_date"`
|
||||
Status string `json:"status"`
|
||||
ApprovalComment string `json:"approval_comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ApplyItem represents an item in an application order
|
||||
type ApplyItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ApplyOrderID int64 `json:"apply_order_id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
ApplyOrderID int64 `json:"apply_order_id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// InboundOrder represents an inbound order
|
||||
type InboundOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
InboundDate time.Time `json:"inbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
InboundDate time.Time `json:"inbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Items []InboundItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +76,13 @@ type InboundItem struct {
|
|||
|
||||
// OutboundOrder represents an outbound order
|
||||
type OutboundOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
OutboundDate time.Time `json:"outbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
OutboundDate time.Time `json:"outbound_date"`
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Items []OutboundItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -91,83 +98,107 @@ type OutboundItem struct {
|
|||
|
||||
// StockLog represents a stock change log
|
||||
type StockLog struct {
|
||||
ID int64 `json:"id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
Type string `json:"type"`
|
||||
Quantity int `json:"quantity"`
|
||||
RelatedOrderType string `json:"related_order_type"`
|
||||
RelatedOrderID int64 `json:"related_order_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RunningBalance int `json:"running_balance,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
Type string `json:"type"`
|
||||
Quantity int `json:"quantity"`
|
||||
RelatedOrderType string `json:"related_order_type"`
|
||||
RelatedOrderID int64 `json:"related_order_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RunningBalance decimal.Decimal `json:"running_balance,omitempty"`
|
||||
}
|
||||
|
||||
// Stock represents stock information with material details
|
||||
type Stock struct {
|
||||
MaterialID int64 `json:"material_id"`
|
||||
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"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
MaterialCode string `json:"material_code"`
|
||||
Name string `json:"name"`
|
||||
Spec string `json:"spec,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
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
|
||||
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"`
|
||||
ReferenceID int64 `json:"reference_id"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
ChangeType string `json:"change_type"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// InboundRecord represents inbound records for a specific material
|
||||
type InboundRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
InboundID int64 `json:"inbound_id"`
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
Quantity int `json:"quantity"`
|
||||
ID int64 `json:"id"`
|
||||
InboundID int64 `json:"inbound_id"`
|
||||
Handler string `json:"handler"`
|
||||
Supplier string `json:"supplier"`
|
||||
Quantity int `json:"quantity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OutboundRecord represents outbound records for a specific material
|
||||
type OutboundRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
OutboundID int64 `json:"outbound_id"`
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver"`
|
||||
Quantity int `json:"quantity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
OutboundID int64 `json:"outbound_id"`
|
||||
Handler string `json:"handler"`
|
||||
Receiver string `json:"receiver"`
|
||||
Quantity int `json:"quantity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ============= Request DTOs =============
|
||||
|
||||
// CreateMaterialRequest represents the request to create a material
|
||||
type CreateMaterialRequest struct {
|
||||
MaterialCode string `json:"material_code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
MaterialImage string `json:"material_image"`
|
||||
Spec string `json:"spec"`
|
||||
Category string `json:"category"`
|
||||
Unit string `json:"unit"`
|
||||
WarningStock int `json:"warning_stock"`
|
||||
OrganizationID int64 `json:"organization_id"`
|
||||
MaterialCode string `json:"material_code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
MaterialImage *string `json:"material_image"`
|
||||
Spec string `json:"spec"`
|
||||
ManufacturerModel *string `json:"manufacturer_model"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
BaseUnitID *int64 `json:"base_unit_id"`
|
||||
Unit string `json:"unit"`
|
||||
WarningStock decimal.Decimal `json:"warning_stock"`
|
||||
}
|
||||
|
||||
// UpdateMaterialRequest represents the request to update a material
|
||||
type UpdateMaterialRequest struct {
|
||||
MaterialCode string `json:"material_code"`
|
||||
Name string `json:"name"`
|
||||
MaterialImage string `json:"material_image"`
|
||||
Spec string `json:"spec"`
|
||||
Category string `json:"category"`
|
||||
Unit string `json:"unit"`
|
||||
WarningStock int `json:"warning_stock"`
|
||||
MaterialCode string `json:"material_code"`
|
||||
Name string `json:"name"`
|
||||
MaterialImage *string `json:"material_image"`
|
||||
Spec string `json:"spec"`
|
||||
ManufacturerModel *string `json:"manufacturer_model"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
BaseUnitID *int64 `json:"base_unit_id"`
|
||||
Unit string `json:"unit"`
|
||||
Status string `json:"status"`
|
||||
WarningStock decimal.Decimal `json:"warning_stock"`
|
||||
}
|
||||
|
||||
// CreateInboundRequest represents the request to create an inbound order
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -254,16 +286,16 @@ const (
|
|||
|
||||
// MaterialApplication represents a material application record
|
||||
type MaterialApplication struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RejectionReason string `json:"rejection_reason,omitempty"`
|
||||
ApprovedBy int64 `json:"approved_by,omitempty"`
|
||||
ApproverName string `json:"approver_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RejectionReason string `json:"rejection_reason,omitempty"`
|
||||
ApprovedBy int64 `json:"approved_by,omitempty"`
|
||||
ApproverName string `json:"approver_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ApplicationItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -281,9 +313,9 @@ type ApplicationItem struct {
|
|||
ID int64 `json:"id"`
|
||||
ApplicationID int64 `json:"application_id"`
|
||||
MaterialID int64 `json:"material_id"`
|
||||
MaterialCode string `json:"material_code"`
|
||||
MaterialName string `json:"material_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaterialCode string `json:"material_code"`
|
||||
MaterialName string `json:"material_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
// ApproveApplicationRequest represents request to approve application
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -338,4 +312,4 @@ func (r *ApplicationRepository) InitApplicationTable() error {
|
|||
)`
|
||||
_, err = database.DB.Exec(itemsQuery)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¤tStock)
|
||||
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(¤tStock); 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) Delete(id int64) error {
|
||||
query := `DELETE FROM users WHERE id = ?`
|
||||
_, err := database.DB.Exec(query, id)
|
||||
return err
|
||||
func (r *UserRepository) Update(user *model.User, roleIDs []int64) error {
|
||||
return r.update(user, roleIDs, false)
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64) error {
|
||||
return r.update(user, roleIDs, true)
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserExists checks if a user exists by username
|
||||
func (r *UserRepository) UserExists(username string) (bool, error) {
|
||||
query := `SELECT COUNT(*) FROM users WHERE username = ?`
|
||||
var count int
|
||||
err := database.DB.QueryRow(query, username).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// InitUserTable creates the users table if it doesn't exist
|
||||
func InitUserTable() error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','user') NOT NULL DEFAULT 'user',
|
||||
description VARCHAR(500) DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
_, err := database.DB.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateDefaultUsers creates default admin and user if no users exist
|
||||
func CreateDefaultUsers() error {
|
||||
repo := NewUserRepository()
|
||||
|
||||
exists, err := repo.UserExists("admin")
|
||||
func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePassword bool) error {
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
roleIDs = uniqueRoleIDs(roleIDs)
|
||||
if err := validateRolesTx(tx, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if updatePassword {
|
||||
_, err = tx.Exec(`UPDATE users SET password = ?, description = ? WHERE id = ?`, user.Password, user.Description, user.ID)
|
||||
} else {
|
||||
_, err = tx.Exec(`UPDATE users SET description = ? WHERE id = ?`, user.Description, user.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM user_roles WHERE user_id = ?`, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = insertUserRolesTx(tx, user.ID, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id int64) error {
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := protectLastSuperAdminTx(tx, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func loadRolesForUsers(queryer interface {
|
||||
Query(query string, args ...interface{}) (*sql.Rows, error)
|
||||
}, users []*model.User) error {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default passwords will be hashed in CreateUser
|
||||
admin := &model.User{
|
||||
Username: "admin",
|
||||
Password: "", // Will be set by service
|
||||
Role: "admin",
|
||||
Description: "系统管理员",
|
||||
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()
|
||||
}
|
||||
|
||||
// Use bcrypt to hash the default passwords
|
||||
func assignRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
|
||||
roleIDs = uniqueRoleIDs(roleIDs)
|
||||
if err := validateRolesTx(tx, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return insertUserRolesTx(tx, userID, roleIDs)
|
||||
}
|
||||
|
||||
func validateRolesTx(tx *sql.Tx, roleIDs []int64) error {
|
||||
if len(roleIDs) == 0 {
|
||||
return ErrInvalidRoles
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(roleIDs)), ", ")
|
||||
args := make([]interface{}, len(roleIDs))
|
||||
for index, id := range roleIDs {
|
||||
args[index] = id
|
||||
}
|
||||
var count int
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (%s)`, placeholders)
|
||||
if err := tx.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(roleIDs) {
|
||||
return ErrInvalidRoles
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertUserRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
|
||||
values := strings.TrimSuffix(strings.Repeat("(?, ?), ", len(roleIDs)), ", ")
|
||||
args := make([]interface{}, 0, len(roleIDs)*2)
|
||||
for _, roleID := range roleIDs {
|
||||
args = append(args, userID, roleID)
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO user_roles (user_id, role_id) VALUES `+values, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func protectLastSuperAdminTx(tx *sql.Tx, userID int64, replacementRoleIDs []int64) error {
|
||||
var superAdminRoleID int64
|
||||
if err := tx.QueryRow(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`).Scan(&superAdminRoleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, roleID := range replacementRoleIDs {
|
||||
if roleID == superAdminRoleID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var currentMembership int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`, userID, superAdminRoleID).Scan(¤tMembership); 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: "普通用户",
|
||||
}
|
||||
|
||||
return repo.Create(user)
|
||||
user := &model.User{Username: "user", Password: string(userHash), Description: "普通用户"}
|
||||
return repo.Create(user, []int64{warehouseRoleID})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,23 +3,28 @@ package service
|
|||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrApplicationNotFound = errors.New("application not found")
|
||||
ErrApplicationNotFound = errors.New("application not found")
|
||||
ErrCannotCancelApplication = errors.New("cannot cancel application")
|
||||
ErrApplicationNotPending = errors.New("application is not pending")
|
||||
ErrApplicationNotPending = errors.New("application is not pending")
|
||||
)
|
||||
|
||||
type ApplicationService struct {
|
||||
appRepo *repository.ApplicationRepository
|
||||
appRepo *repository.ApplicationRepository
|
||||
materialSvc *MaterialService
|
||||
stockRepo *repository.StockRepo
|
||||
}
|
||||
|
||||
func NewApplicationService() *ApplicationService {
|
||||
return &ApplicationService{
|
||||
appRepo: repository.NewApplicationRepository(),
|
||||
appRepo: repository.NewApplicationRepository(),
|
||||
materialSvc: NewMaterialService(),
|
||||
stockRepo: repository.NewStockRepo(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,9 +32,8 @@ func (s *ApplicationService) Create(userID int64, req *model.CreateApplicationRe
|
|||
// Build items from request
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,20 +30,26 @@ func NewAuthService() *AuthService {
|
|||
|
||||
// SimpleToken represents a simple JWT-like token
|
||||
type SimpleToken struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Exp int64 `json:"exp"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"roles"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// GenerateToken creates a simple token for the user
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ package service
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
)
|
||||
|
||||
type InboundService struct {
|
||||
inboundRepo *repository.InboundRepo
|
||||
materialSvc *MaterialService
|
||||
stockRepo *repository.StockRepo
|
||||
inboundRepo *repository.InboundRepo
|
||||
materialSvc *MaterialService
|
||||
stockRepo *repository.StockRepo
|
||||
}
|
||||
|
||||
func NewInboundService() *InboundService {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import (
|
|||
)
|
||||
|
||||
type MaterialService struct {
|
||||
repo *repository.MaterialRepo
|
||||
logSvc *MaterialLogService
|
||||
repo *repository.MaterialRepo
|
||||
logSvc *MaterialLogService
|
||||
}
|
||||
|
||||
func NewMaterialService() *MaterialService {
|
||||
|
|
@ -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{
|
||||
MaterialCode: req.MaterialCode,
|
||||
Name: req.Name,
|
||||
MaterialImage: materialImage,
|
||||
Spec: req.Spec,
|
||||
Category: req.Category,
|
||||
Unit: unit,
|
||||
WarningStock: req.WarningStock,
|
||||
OrganizationID: organizationID,
|
||||
MaterialCode: req.MaterialCode,
|
||||
Name: req.Name,
|
||||
MaterialImage: materialImage,
|
||||
Spec: req.Spec,
|
||||
CategoryID: req.CategoryID,
|
||||
BaseUnitID: req.BaseUnitID,
|
||||
Unit: unit,
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
)
|
||||
|
|
@ -20,7 +21,7 @@ func NewOutboundService() *OutboundService {
|
|||
return &OutboundService{
|
||||
outboundRepo: repository.NewOutboundRepo(),
|
||||
materialSvc: NewMaterialService(),
|
||||
stockRepo: repository.NewStockRepo(),
|
||||
stockRepo: repository.NewStockRepo(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user