物料分类重构、物料恢复、入库预警阈值与库存明细接口

This commit is contained in:
文睿 张 2026-09-18 17:20:45 +08:00
parent 6a443577b0
commit 6fe9d0da40
24 changed files with 1741 additions and 557 deletions

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ export const stockApi = {
getInventoryBalanceDetails(materialId) { getInventoryBalanceDetails(materialId) {
return request.get(`/stock/${materialId}/details`) return request.get(`/stock/${materialId}/details`)
}, },
updateWarning(materialId, warningStock) { updateWarning(materialId, warningStock, enabled = true) {
return request.put(`/stock/${materialId}/warning`, { warning_stock: warningStock }) return request.put(`/stock/${materialId}/warning`, { warning_stock: warningStock, enabled })
} }
} }

View File

@ -10,7 +10,8 @@ export const useStockStore = defineStore('stock', () => {
const stockPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) const stockPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
const warningPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) const warningPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
const logsPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) const logsPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
// Cache for per-material stock lookup (materialId -> total_on_hand) // 单物料库存缓存(materialId -> 库存对象)。出库和申请只要 total_on_hand,
// 入库表单还要读预警阈值,共用同一份缓存避免重复请求。
const stockCache = ref({}) const stockCache = ref({})
async function fetchStock(page = 1, search = '', category = '') { async function fetchStock(page = 1, search = '', category = '') {
@ -38,22 +39,29 @@ export const useStockStore = defineStore('stock', () => {
} }
} }
async function fetchStockByMaterialId(materialId) { // 返回完整的库存对象,供需要预警阈值的场景使用。
if (!materialId) return 0 async function fetchStockDetailByMaterialId(materialId) {
if (!materialId) return null
if (stockCache.value[materialId] !== undefined) { if (stockCache.value[materialId] !== undefined) {
return stockCache.value[materialId] return stockCache.value[materialId]
} }
try { try {
const res = await stockApi.getById(materialId) const res = await stockApi.getById(materialId)
const stock = res.data?.total_on_hand ?? 0 const detail = res.data ?? null
stockCache.value[materialId] = stock stockCache.value[materialId] = detail
return stock return detail
} catch { } catch {
stockCache.value[materialId] = 0 stockCache.value[materialId] = null
return 0 return null
} }
} }
async function fetchStockByMaterialId(materialId) {
if (!materialId) return 0
const detail = await fetchStockDetailByMaterialId(materialId)
return detail?.total_on_hand ?? 0
}
async function fetchStockWarning(page = 1) { async function fetchStockWarning(page = 1) {
loading.value = true loading.value = true
try { try {
@ -84,8 +92,10 @@ export const useStockStore = defineStore('stock', () => {
} }
} }
async function updateWarningStock(materialId, warningStock) { async function updateWarningStock(materialId, warningStock, enabled = true) {
await stockApi.updateWarning(materialId, warningStock) await stockApi.updateWarning(materialId, warningStock, enabled)
// 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。
delete stockCache.value[materialId]
await Promise.all([ await Promise.all([
fetchStock(stockPage.value.currentPage), fetchStock(stockPage.value.currentPage),
fetchStockWarning(warningPage.value.currentPage) fetchStockWarning(warningPage.value.currentPage)
@ -95,7 +105,8 @@ export const useStockStore = defineStore('stock', () => {
return { return {
stockList, warningList, logs, loading, stockList, warningList, logs, loading,
stockPage, warningPage, logsPage, stockPage, warningPage, logsPage,
fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockWarning, fetchStockLogs, fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockDetailByMaterialId,
fetchStockWarning, fetchStockLogs,
updateWarningStock updateWarningStock
} }
}) })

View File

@ -56,23 +56,42 @@
</el-form-item> </el-form-item>
<el-form-item label="入库物料"> <el-form-item label="入库物料">
<div v-for="(item, index) in form.items" :key="index" class="item-row"> <div v-for="(item, index) in form.items" :key="index" class="item-row">
<el-select <div class="item-row__main">
v-model="item.material_id" <el-select
placeholder="输入物料编码或名称搜索" v-model="item.material_id"
filterable placeholder="输入物料编码或名称搜索"
:filter-method="(val) => materialFilter(val, item)" filterable
style="width: 280px" :filter-method="(val) => materialFilter(val, item)"
@change="(id) => onMaterialChange(item, id)" style="width: 280px"
> @change="(id) => onMaterialChange(item, id)"
<el-option >
v-for="m in getFilteredMaterials(item)" <el-option
:key="m.id" v-for="m in getFilteredMaterials(item)"
:label="`${m.material_code} - ${m.name}`" :key="m.id"
:value="m.id" :label="`${m.material_code} - ${m.name}`"
/> :value="m.id"
</el-select> />
<el-input-number v-model="item.quantity" :min="1" /> </el-select>
<el-button @click="removeItem(index)" :disabled="form.items.length <= 1">删除</el-button> <el-input-number v-model="item.quantity" :min="1" />
</div>
<div class="item-row__extra">
<span class="warning-field">
<span class="warning-label">预警值</span>
<el-input-number
v-model="item.warning_stock"
:min="0"
:step="1"
:disabled="!item.material_id"
placeholder="不设置"
@change="markWarningEdited(item)"
/>
</span>
<el-button
type="danger"
@click="removeItem(index)"
:disabled="form.items.length <= 1"
>删除</el-button>
</div>
</div> </div>
<el-button @click="addItem">添加物料</el-button> <el-button @click="addItem">添加物料</el-button>
</el-form-item> </el-form-item>
@ -112,6 +131,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useInboundStore } from '../stores/inbound' import { useInboundStore } from '../stores/inbound'
import { useMaterialsStore } from '../stores/materials' import { useMaterialsStore } from '../stores/materials'
import { useStockStore } from '../stores/stock'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
@ -120,6 +140,7 @@ import { ElMessage } from 'element-plus'
const inboundStore = useInboundStore() const inboundStore = useInboundStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
const stockStore = useStockStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const dialogVisible = ref(false) const dialogVisible = ref(false)
@ -131,11 +152,24 @@ const editingId = ref(null)
const deleteId = ref(null) const deleteId = ref(null)
const currentRow = ref(null) const currentRow = ref(null)
// 明细行的初始结构。预警阈值单独放在 warning_stock 上,并配一个"用户是否改过"的标记:
// 只有真正被改动过的明细才会把阈值提交给后端,避免编辑旧入库单时把物料当前的阈值
// 覆盖回表单里的旧数。
function createItem() {
return {
material_id: null,
materialSearchText: '',
quantity: 1,
warning_stock: null,
warning_stock_edited: false
}
}
const form = reactive({ const form = reactive({
handler: '', handler: '',
supplier: '', supplier: '',
remark: '', remark: '',
items: [{ material_id: null, materialSearchText: '', quantity: 1 }] items: [createItem()]
}) })
onMounted(() => { onMounted(() => {
@ -150,7 +184,7 @@ function handlePageChange(page) {
function showAddModal() { function showAddModal() {
editingId.value = null editingId.value = null
dialogTitle.value = '新增入库单' dialogTitle.value = '新增入库单'
Object.assign(form, { handler: '', supplier: '', remark: '', items: [{ material_id: null, materialSearchText: '', quantity: 1 }] }) Object.assign(form, { handler: '', supplier: '', remark: '', items: [createItem()] })
dialogVisible.value = true dialogVisible.value = true
} }
@ -160,10 +194,18 @@ function showEditModal(row) {
const items = row.items && row.items.length > 0 const items = row.items && row.items.length > 0
? row.items.map(item => { ? row.items.map(item => {
const material = materialsStore.materials.find(m => m.id === item.material_id) const material = materialsStore.materials.find(m => m.id === item.material_id)
return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : '', quantity: item.quantity } return {
material_id: item.material_id,
materialSearchText: material ? `${material.material_code} - ${material.name}` : '',
quantity: item.quantity,
warning_stock: null,
warning_stock_edited: false
}
}) })
: [{ material_id: null, materialSearchText: '', quantity: 1 }] : [createItem()]
Object.assign(form, { handler: row.handler || '', supplier: row.supplier || '', remark: row.remark || '', items }) Object.assign(form, { handler: row.handler || '', supplier: row.supplier || '', remark: row.remark || '', items })
// 预填的是物料"当前"的阈值,不是这张单当年的值;用户不动它就不会被提交回去。
form.items.forEach(item => prefillWarningStock(item))
dialogVisible.value = true dialogVisible.value = true
} }
@ -181,7 +223,20 @@ async function showDetail(row) {
} }
function addItem() { function addItem() {
form.items.push({ material_id: null, materialSearchText: '', quantity: 1 }) form.items.push(createItem())
}
function markWarningEdited(item) {
item.warning_stock_edited = true
}
// 把物料当前的预警阈值预填到明细行方便沿用,用户不动它就不会被提交。
async function prefillWarningStock(item) {
if (!item.material_id) return
const detail = await stockStore.fetchStockDetailByMaterialId(item.material_id)
if (detail?.warning_stock !== undefined) {
item.warning_stock = Number(detail.warning_stock) || 0
}
} }
function removeItem(index) { function removeItem(index) {
@ -203,15 +258,26 @@ function getFilteredMaterials(item) {
function onMaterialChange(item, id) { function onMaterialChange(item, id) {
const idx = form.items.indexOf(item) const idx = form.items.indexOf(item)
if (idx !== -1) { if (idx === -1) return
form.items.splice(idx, 1, { ...item }) const next = { ...item, warning_stock: null, warning_stock_edited: false }
} form.items.splice(idx, 1, next)
// splice 之后要取回数组里的代理对象再改,直接改 next 会绕过响应式
prefillWarningStock(form.items[idx])
} }
async function handleSave() { async function handleSave() {
saveLoading.value = true saveLoading.value = true
try { try {
const validItems = form.items.filter(item => item.material_id && item.quantity > 0) const validItems = form.items
.filter(item => item.material_id && item.quantity > 0)
.map(item => {
const payload = { material_id: item.material_id, quantity: item.quantity }
// 不带 warning_stock 表示不改动服务端已存的阈值,只有用户改过的明细才提交
if (item.warning_stock_edited && item.warning_stock !== null) {
payload.warning_stock = item.warning_stock
}
return payload
})
if (validItems.length === 0) { if (validItems.length === 0) {
ElMessage.warning('请至少添加一个物料') ElMessage.warning('请至少添加一个物料')
return return
@ -256,9 +322,29 @@ function formatDate(dateStr) {
margin-bottom: 20px; margin-bottom: 20px;
} }
.item-row { .item-row {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.item-row__main {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-bottom: 10px; }
.item-row__extra {
display: flex;
align-items: center;
gap: 12px;
}
.warning-field {
display: flex;
align-items: center;
gap: 6px;
}
.warning-label {
color: var(--el-text-color-secondary);
font-size: 12px;
white-space: nowrap;
} }
.mt-3 { .mt-3 {
margin-top: 16px; margin-top: 16px;

View File

@ -54,7 +54,8 @@
<el-table-column prop="warning_stock" label="预警值" /> <el-table-column prop="warning_stock" label="预警值" />
<el-table-column label="状态" :class-name="'pr-3'"> <el-table-column label="状态" :class-name="'pr-3'">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="isLowStock(row)" type="danger">库存不足</el-tag> <!-- 状态完全由后端的 stock_status 决定,前端不再重算,避免和预警列表口径漂移 -->
<el-tag v-if="row.stock_status === 'low'" type="danger">库存不足</el-tag>
<el-tag v-else type="success">正常</el-tag> <el-tag v-else type="success">正常</el-tag>
</template> </template>
</el-table-column> </el-table-column>
@ -158,8 +159,12 @@
<el-form-item label="物料"> <el-form-item label="物料">
<span>{{ warningMaterial?.material_code }} - {{ warningMaterial?.name }}</span> <span>{{ warningMaterial?.material_code }} - {{ warningMaterial?.name }}</span>
</el-form-item> </el-form-item>
<el-form-item label="启用预警">
<el-switch v-model="warningEnabled" />
<span class="form-hint">关闭后该物料不再出现在库存预警列表,阈值会保留</span>
</el-form-item>
<el-form-item label="预警阈值"> <el-form-item label="预警阈值">
<el-input-number v-model="warningValue" :min="0" :step="1" /> <el-input-number v-model="warningValue" :min="0" :step="1" :disabled="!warningEnabled" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -176,8 +181,8 @@
<div v-if="detailMaterial" class="detail-header"> <div v-if="detailMaterial" class="detail-header">
<span>物料ID:{{ detailMaterial.material_id }}</span> <span>物料ID:{{ detailMaterial.material_id }}</span>
<span>编码:{{ detailMaterial.material_code }}</span> <span>编码:{{ detailMaterial.material_code }}</span>
<span>{{ detailMaterial.name }}</span> <span>名称:{{ detailMaterial.name }}</span>
<span>{{ detailMaterial.spec }}</span> <span>规格:{{ detailMaterial.spec }}</span>
<span>库存数量:{{ detailMaterial.total_on_hand }}</span> <span>库存数量:{{ detailMaterial.total_on_hand }}</span>
</div> </div>
<el-table :data="balanceDetails" stripe class="mt-3"> <el-table :data="balanceDetails" stripe class="mt-3">
@ -209,7 +214,6 @@ import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import WarehouseSettings from './inventory/WarehouseSettings.vue' import WarehouseSettings from './inventory/WarehouseSettings.vue'
import { isLowStock } from '../utils/quantity'
import { searchMaterialOptions } from '../utils/materialSearch' import { searchMaterialOptions } from '../utils/materialSearch'
import { materialCategoriesApi } from '../api/masterData' import { materialCategoriesApi } from '../api/masterData'
import { getMajorCategories } from '../utils/materialCategories' import { getMajorCategories } from '../utils/materialCategories'
@ -241,6 +245,7 @@ const warningDialogVisible = ref(false)
const warningSaving = ref(false) const warningSaving = ref(false)
const warningMaterial = ref(null) const warningMaterial = ref(null)
const warningValue = ref(0) const warningValue = ref(0)
const warningEnabled = ref(true)
onMounted(async () => { onMounted(async () => {
stockStore.fetchStock() stockStore.fetchStock()
@ -281,6 +286,9 @@ function handlePageChange(page) {
function openWarningDialog(row) { function openWarningDialog(row) {
warningMaterial.value = row warningMaterial.value = row
warningValue.value = row.warning_stock || 0 warningValue.value = row.warning_stock || 0
// 后端对"没配过规则"返回启用,只有显式关闭过的才读回 false
warningEnabled.value = row.warning_enabled !== false
warningDialogVisible.value = true
} }
async function openDetailDialog(row) { async function openDetailDialog(row) {
@ -299,7 +307,7 @@ async function saveWarning() {
if (!warningMaterial.value) return if (!warningMaterial.value) return
warningSaving.value = true warningSaving.value = true
try { try {
await stockStore.updateWarningStock(warningMaterial.value.material_id, warningValue.value) await stockStore.updateWarningStock(warningMaterial.value.material_id, warningValue.value, warningEnabled.value)
warningDialogVisible.value = false warningDialogVisible.value = false
ElMessage.success('库存预警已更新') ElMessage.success('库存预警已更新')
} catch (error) { } catch (error) {
@ -382,6 +390,11 @@ function getReferenceTypeName(type) {
content: '|'; content: '|';
margin-right: 20px; margin-right: 20px;
} }
.form-hint {
margin-left: 12px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.mt-3 { .mt-3 {
margin-top: 12px; margin-top: 12px;
} }

View File

@ -0,0 +1,72 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import MasterDataSettings from './MasterDataSettings.vue'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData'
vi.mock('../../api/masterData', () => ({
materialCategoriesApi: { list: vi.fn(), create: vi.fn(), update: vi.fn() },
unitsApi: { list: vi.fn(), create: vi.fn(), update: vi.fn() },
codeRulesApi: { list: vi.fn(), create: vi.fn(), update: vi.fn() }
}))
const STANDARD_PART_DESCRIPTION =
'1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。'
function mountSettings() {
return mount(MasterDataSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
}
function detailButtons(wrapper) {
return wrapper.findAll('button').filter(button => button.text() === '描述详情')
}
describe('物料分类描述详情', () => {
beforeEach(() => {
materialCategoriesApi.list.mockResolvedValue({
data: [
{ id: 1, code: 'A', name: '机械结构类', description: '纯机械传动件纳入本类管理。', parent_id: null, status: 'active' },
{ id: 2, code: 'A01', name: '标准件', description: STANDARD_PART_DESCRIPTION, parent_id: 1, status: 'active' },
{ id: 3, code: 'A02', name: '成品件', description: '', parent_id: 1, status: 'active' }
]
})
unitsApi.list.mockResolvedValue({ data: [] })
codeRulesApi.list.mockResolvedValue({ data: [] })
})
test('每一行都提供描述详情按钮,描述正文不直接铺在表格里', async () => {
const wrapper = mountSettings()
await flushPromises()
expect(detailButtons(wrapper)).toHaveLength(3)
expect(wrapper.text()).not.toContain('国家/行业通用标准化零件')
wrapper.unmount()
})
test('点击描述详情后弹窗展示该行的完整描述', async () => {
const wrapper = mountSettings()
await flushPromises()
await detailButtons(wrapper)[1].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('A01 标准件')
expect(wrapper.text()).toContain(STANDARD_PART_DESCRIPTION)
expect(wrapper.text()).not.toContain('纯机械传动件纳入本类管理。')
wrapper.unmount()
})
test('描述为空时弹窗显示占位文案', async () => {
const wrapper = mountSettings()
await flushPromises()
await detailButtons(wrapper)[2].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('A02 成品件')
expect(wrapper.text()).toContain('暂无描述')
wrapper.unmount()
})
})

View File

@ -29,6 +29,11 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="activeTab === 'categories'" label="描述" width="110">
<template #default="{ row }">
<el-button link type="primary" @click="openDescriptionDetail(row)">描述详情</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="100"> <el-table-column label="操作" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
@ -56,6 +61,9 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="activeTab === 'categories'" label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="分类定义、包含范围与边界区分" />
</el-form-item>
<el-form-item v-if="activeTab === 'units'" label="小数位"> <el-form-item v-if="activeTab === 'units'" label="小数位">
<el-input-number v-model="form.precision_scale" :min="0" :max="6" /> <el-input-number v-model="form.precision_scale" :min="0" :max="6" />
</el-form-item> </el-form-item>
@ -80,6 +88,14 @@
<el-button type="primary" :loading="saving" @click="save">保存</el-button> <el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="descriptionDetailVisible" title="描述详情" width="560px">
<p class="description-detail__subject">{{ descriptionDetailSubject }}</p>
<p class="description-detail__body">{{ descriptionDetailText }}</p>
<template #footer>
<el-button type="primary" @click="descriptionDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@ -105,7 +121,7 @@ const rootCategories = computed(() => categories.value.filter(item => !item.pare
const form = reactive(defaultForm()) const form = reactive(defaultForm())
function defaultForm() { function defaultForm() {
return { code: '', name: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, precision_scale: 0, status: 'active' } return { code: '', name: '', description: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, precision_scale: 0, status: 'active' }
} }
function parentCategoryLabel(parentId) { function parentCategoryLabel(parentId) {
@ -158,6 +174,19 @@ async function save() {
} }
} }
const descriptionDetailVisible = ref(false)
const descriptionDetailRow = ref(null)
const descriptionDetailSubject = computed(() => {
const row = descriptionDetailRow.value
return row ? `${row.code} ${row.name}` : ''
})
const descriptionDetailText = computed(() => descriptionDetailRow.value?.description || '暂无描述')
function openDescriptionDetail(row) {
descriptionDetailRow.value = row
descriptionDetailVisible.value = true
}
onMounted(loadCurrent) onMounted(loadCurrent)
</script> </script>
@ -165,4 +194,6 @@ onMounted(loadCurrent)
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.page-header h2 { margin: 0 0 6px; } .page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; } .page-header p { margin: 0; color: #909399; }
.description-detail__subject { margin: 0 0 10px; font-weight: 600; }
.description-detail__body { margin: 0; color: #606266; line-height: 1.7; white-space: pre-wrap; }
</style> </style>

View File

@ -83,6 +83,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
materials.POST("", h.CreateMaterial) materials.POST("", h.CreateMaterial)
materials.PUT("/:id", h.UpdateMaterial) materials.PUT("/:id", h.UpdateMaterial)
materials.POST("/:id/obsolete", h.ObsoleteMaterial) materials.POST("/:id/obsolete", h.ObsoleteMaterial)
materials.POST("/:id/restore", h.RestoreMaterial)
} }
inbound := api.Group("/inbound") inbound := api.Group("/inbound")
@ -115,6 +116,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
stock.GET("/logs", h.GetAllStockLogs) stock.GET("/logs", h.GetAllStockLogs)
stock.GET("/logs/:materialId", h.GetStockLogs) stock.GET("/logs/:materialId", h.GetStockLogs)
stock.PUT("/:materialId/warning", h.UpdateWarningStock) stock.PUT("/:materialId/warning", h.UpdateWarningStock)
stock.GET("/:materialId/details", h.GetInventoryBalanceDetails)
stock.GET("/:materialId", h.GetStock) stock.GET("/:materialId", h.GetStock)
} }
@ -367,12 +369,37 @@ func (h *Handler) ObsoleteMaterial(c *gin.Context) {
response.Error(c, 409, err.Error()) response.Error(c, 409, err.Error())
return return
} }
if strings.Contains(err.Error(), "物料不存在") {
response.Error(c, 404, err.Error())
return
}
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }
response.SuccessWithMessage(c, "Material obsoleted successfully", nil) response.SuccessWithMessage(c, "Material obsoleted successfully", nil)
} }
func (h *Handler) RestoreMaterial(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.Restore(id, operator); err != nil {
if strings.Contains(err.Error(), "物料不存在") {
response.Error(c, 404, err.Error())
return
}
response.InternalError(c, err.Error())
return
}
response.SuccessWithMessage(c, "Material restored successfully", nil)
}
// Inbound handlers // Inbound handlers
func (h *Handler) ListInbound(c *gin.Context) { func (h *Handler) ListInbound(c *gin.Context) {
@ -694,6 +721,35 @@ func (h *Handler) GetStock(c *gin.Context) {
response.Success(c, stock) response.Success(c, stock)
} }
// GetInventoryBalanceDetails 返回某物料在各组织/仓库/库位上的库存明细
func (h *Handler) GetInventoryBalanceDetails(c *gin.Context) {
materialID, err := strconv.ParseInt(c.Param("materialId"), 10, 64)
if err != nil {
response.BadRequest(c, "invalid material id")
return
}
exists, err := h.stockSvc.MaterialExists(materialID)
if err != nil {
response.InternalError(c, err.Error())
return
}
if !exists {
response.NotFound(c, "Material not found")
return
}
details, err := h.stockSvc.GetInventoryBalanceDetails(materialID)
if err != nil {
response.InternalError(c, err.Error())
return
}
if details == nil {
details = []model.InventoryBalanceDetail{}
}
response.Success(c, details)
}
func (h *Handler) GetWarningStock(c *gin.Context) { func (h *Handler) GetWarningStock(c *gin.Context) {
limit := 20 limit := 20
offset := 0 offset := 0
@ -730,6 +786,9 @@ func (h *Handler) UpdateWarningStock(c *gin.Context) {
} }
var req struct { var req struct {
WarningStock decimal.Decimal `json:"warning_stock"` WarningStock decimal.Decimal `json:"warning_stock"`
// Enabled 用指针接收:不传表示沿用旧行为(启用),只有显式传 false 才是关闭
// 该物料的预警。
Enabled *bool `json:"enabled"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
@ -739,7 +798,11 @@ func (h *Handler) UpdateWarningStock(c *gin.Context) {
response.BadRequest(c, "warning_stock must be greater than or equal to 0") response.BadRequest(c, "warning_stock must be greater than or equal to 0")
return return
} }
if err := h.stockSvc.UpdateWarningStock(materialID, req.WarningStock); err != nil { enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
if err := h.stockSvc.UpdateWarningStock(materialID, req.WarningStock, enabled); err != nil {
response.InternalError(c, err.Error()) response.InternalError(c, err.Error())
return return
} }

View File

@ -23,7 +23,8 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) {
"PUT /api/roles/:id", "GET /api/permissions", "PUT /api/roles/:id", "GET /api/permissions",
"GET /api/partners", "POST /api/partners", "PUT /api/partners/:id", "GET /api/partners", "POST /api/partners", "PUT /api/partners/:id",
"GET /api/warehouses", "POST /api/warehouses/:id/locations", "GET /api/warehouses", "POST /api/warehouses/:id/locations",
"POST /api/materials/:id/obsolete", "PUT /api/stock/:materialId/warning", "POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore",
"PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details",
"POST /api/applications", "GET /api/logs", "POST /api/applications", "GET /api/logs",
} }
for _, route := range want { for _, route := range want {

View File

@ -5,6 +5,7 @@ type MaterialCategory struct {
OrganizationID int64 `json:"organization_id"` OrganizationID int64 `json:"organization_id"`
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"`
ParentID *int64 `json:"parent_id,omitempty"` ParentID *int64 `json:"parent_id,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }

View File

@ -110,17 +110,26 @@ type StockLog struct {
// Stock represents stock information with material details // Stock represents stock information with material details
type Stock struct { type Stock struct {
MaterialID int64 `json:"material_id"` MaterialID int64 `json:"material_id"`
MaterialCode string `json:"material_code"` MaterialCode string `json:"material_code"`
Name string `json:"name"` Name string `json:"name"`
Spec string `json:"spec,omitempty"` Spec string `json:"spec,omitempty"`
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
MajorCategory string `json:"major_category"` MajorCategory string `json:"major_category"`
MinorCategory string `json:"minor_category"` MinorCategory string `json:"minor_category"`
TotalOnHand decimal.Decimal `json:"total_on_hand"` TotalOnHand decimal.Decimal `json:"total_on_hand"`
WarningStock decimal.Decimal `json:"warning_stock"` WarningStock decimal.Decimal `json:"warning_stock"`
WarningEnabled bool `json:"warning_enabled"`
StockStatus string `json:"stock_status"`
} }
// Stock.StockStatus 的取值。只有配置了启用状态且大于 0 的预警阈值、并且现存量已经
// 触达该阈值时才是 low,其余一律为 normal。
const (
StockStatusLow = "low"
StockStatusNormal = "normal"
)
// InventoryBalanceDetail represents detailed inventory balance // InventoryBalanceDetail represents detailed inventory balance
type InventoryBalanceDetail struct { type InventoryBalanceDetail struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@ -175,30 +184,28 @@ type OutboundRecord struct {
// CreateMaterialRequest represents the request to create a material // CreateMaterialRequest represents the request to create a material
type CreateMaterialRequest struct { type CreateMaterialRequest struct {
OrganizationID int64 `json:"organization_id"` OrganizationID int64 `json:"organization_id"`
MaterialCode string `json:"material_code" binding:"required"` MaterialCode string `json:"material_code" binding:"required"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
MaterialImage *string `json:"material_image"` MaterialImage *string `json:"material_image"`
Spec string `json:"spec"` Spec string `json:"spec"`
ManufacturerModel *string `json:"manufacturer_model"` ManufacturerModel *string `json:"manufacturer_model"`
CategoryID *int64 `json:"category_id" binding:"required,gt=0"` CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
BaseUnitID *int64 `json:"base_unit_id"` BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"` Unit string `json:"unit"`
WarningStock decimal.Decimal `json:"warning_stock"`
} }
// UpdateMaterialRequest represents the request to update a material // UpdateMaterialRequest represents the request to update a material
type UpdateMaterialRequest struct { type UpdateMaterialRequest struct {
MaterialCode string `json:"material_code"` MaterialCode string `json:"material_code"`
Name string `json:"name"` Name string `json:"name"`
MaterialImage *string `json:"material_image"` MaterialImage *string `json:"material_image"`
Spec string `json:"spec"` Spec string `json:"spec"`
ManufacturerModel *string `json:"manufacturer_model"` ManufacturerModel *string `json:"manufacturer_model"`
CategoryID *int64 `json:"category_id" binding:"required,gt=0"` CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
BaseUnitID *int64 `json:"base_unit_id"` BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"` Unit string `json:"unit"`
Status string `json:"status"` Status string `json:"status"`
WarningStock decimal.Decimal `json:"warning_stock"`
} }
// CreateInboundRequest represents the request to create an inbound order // CreateInboundRequest represents the request to create an inbound order
@ -208,6 +215,8 @@ type CreateInboundRequest struct {
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`
// WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。
WarningStock *decimal.Decimal `json:"warning_stock"`
} `json:"items" binding:"required,min=1"` } `json:"items" binding:"required,min=1"`
} }
@ -228,6 +237,8 @@ type UpdateInboundRequest struct {
Items []struct { Items []struct {
MaterialID int64 `json:"material_id" binding:"required"` MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"` Quantity int `json:"quantity" binding:"required,gt=0"`
// WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。
WarningStock *decimal.Decimal `json:"warning_stock"`
} `json:"items" binding:"required,min=1"` } `json:"items" binding:"required,min=1"`
} }

View File

@ -10,7 +10,7 @@ type MasterDataRepository struct{}
func NewMasterDataRepository() *MasterDataRepository { return &MasterDataRepository{} } func NewMasterDataRepository() *MasterDataRepository { return &MasterDataRepository{} }
func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.MaterialCategory, error) { func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.MaterialCategory, error) {
rows, err := database.DB.Query(`SELECT id, organization_id, code, name, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`, organizationID) rows, err := database.DB.Query(`SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`, organizationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -18,7 +18,7 @@ func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.Mat
items := make([]model.MaterialCategory, 0) items := make([]model.MaterialCategory, 0)
for rows.Next() { for rows.Next() {
var item model.MaterialCategory var item model.MaterialCategory
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.Code, &item.Name, &item.ParentID, &item.Status); err != nil { if err := rows.Scan(&item.ID, &item.OrganizationID, &item.Code, &item.Name, &item.Description, &item.ParentID, &item.Status); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)
@ -27,7 +27,7 @@ func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.Mat
} }
func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) error { func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) error {
result, err := database.DB.Exec(`INSERT INTO material_categories (organization_id, code, name, parent_id, status) VALUES (?, ?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.ParentID, item.Status) result, err := database.DB.Exec(`INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES (?, ?, ?, ?, ?, ?)`, item.OrganizationID, item.Code, item.Name, item.Description, item.ParentID, item.Status)
if err != nil { if err != nil {
return err return err
} }
@ -36,7 +36,7 @@ func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) erro
} }
func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error { func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error {
_, err := database.DB.Exec(`UPDATE material_categories SET name = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.ParentID, item.Status, item.ID, item.OrganizationID) _, err := database.DB.Exec(`UPDATE material_categories SET name = ?, description = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Description, item.ParentID, item.Status, item.ID, item.OrganizationID)
return err return err
} }

View File

@ -231,8 +231,17 @@ func (r *MaterialRepo) Obsolete(id int64) error {
if nonZero > 0 { if nonZero > 0 {
return fmt.Errorf("该物料仍有库存,无法作废") return fmt.Errorf("该物料仍有库存,无法作废")
} }
_, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id) result, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id)
return err if err != nil {
return err
}
// version always increments, so zero affected rows means the material does
// not exist; re-obsoleting an already-obsolete material still reports 1.
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("物料不存在")
}
return nil
} }
func (r *MaterialRepo) Restore(id int64) error { func (r *MaterialRepo) Restore(id int64) error {

View File

@ -3,6 +3,7 @@ package repository
import ( import (
"encoding/json" "encoding/json"
"regexp" "regexp"
"strings"
"testing" "testing"
"time" "time"
@ -76,3 +77,72 @@ func TestMaterialRepoRecognizesOnlyActiveSubcategories(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
const (
obsoleteStockQuery = `SELECT COUNT(*) FROM inventory_balances WHERE material_id = ? AND (on_hand <> 0 OR reserved <> 0 OR locked <> 0 OR in_transit <> 0)`
obsoleteUpdateStmt = `UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`
)
func newObsoleteMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
database.DB = db
return mock, func() {
database.DB = nil
db.Close()
}
}
func TestMaterialRepoObsoleteRejectsUnknownMaterial(t *testing.T) {
mock, cleanup := newObsoleteMock(t)
t.Cleanup(cleanup)
mock.ExpectQuery(regexp.QuoteMeta(obsoleteStockQuery)).WithArgs(int64(999999)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(regexp.QuoteMeta(obsoleteUpdateStmt)).WithArgs(int64(999999)).
WillReturnResult(sqlmock.NewResult(0, 0))
err := NewMaterialRepo().Obsolete(999999)
if err == nil || !strings.Contains(err.Error(), "物料不存在") {
t.Fatalf("obsoleting a missing material should report 物料不存在, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoObsoleteUpdatesExistingMaterial(t *testing.T) {
mock, cleanup := newObsoleteMock(t)
t.Cleanup(cleanup)
mock.ExpectQuery(regexp.QuoteMeta(obsoleteStockQuery)).WithArgs(int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(regexp.QuoteMeta(obsoleteUpdateStmt)).WithArgs(int64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))
if err := NewMaterialRepo().Obsolete(7); err != nil {
t.Fatalf("obsolete existing material: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialRepoObsoleteIsBlockedByStock(t *testing.T) {
mock, cleanup := newObsoleteMock(t)
t.Cleanup(cleanup)
mock.ExpectQuery(regexp.QuoteMeta(obsoleteStockQuery)).WithArgs(int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
err := NewMaterialRepo().Obsolete(7)
if err == nil || !strings.Contains(err.Error(), "仍有库存") {
t.Fatalf("obsoleting a stocked material should be blocked, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -9,6 +9,30 @@ import (
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
) )
// obsoleteMaterialFilterSQL 把已作废的物料挡在库存视图之外。作废要求现存量已经为 0,
// 所以"不再补货"的物料是走作废退役,而不是永远挂在预警列表里。
const obsoleteMaterialFilterSQL = ` AND m.status <> 'obsolete'`
// stockedMaterialFilterSQL 在此基础上再挡掉从未入库过的物料。"是否存在
// inventory_balances 行"正是"进过仓库"和"只存在于主数据"的分界线;被领用到 0 的行
// 依然算数,所以已耗尽的物料仍然可见。
const stockedMaterialFilterSQL = obsoleteMaterialFilterSQL +
` AND EXISTS (SELECT 1 FROM inventory_balances ib2 WHERE ib2.material_id = m.id)`
// stockWarningHavingSQL 必须与 service.ClassifyStock 保持一致:只有配置了启用状态且大于 0
// 的预警阈值、并且现存量已经触达该阈值时才预警。enabled 的判断刻意放在 HAVING 而不是
// join 条件里,否则被禁用的规则会塌缩成"没配过规则",反过来又被当成启用。
const stockWarningHavingSQL = `HAVING COALESCE(MAX(swr.enabled), TRUE) = TRUE
AND COALESCE(MAX(swr.minimum_available), 0) > 0
AND COALESCE(SUM(ib.on_hand), 0) <= COALESCE(MAX(swr.minimum_available), 0)`
const upsertStockWarningRuleSQL = `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled)
SELECT o.id, w.id, ?, ?, ?
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 = VALUES(enabled)`
type StockRepo struct{} type StockRepo struct{}
func NewStockRepo() *StockRepo { func NewStockRepo() *StockRepo {
@ -16,7 +40,7 @@ func NewStockRepo() *StockRepo {
} }
func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) { func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1` countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1` + stockedMaterialFilterSQL
var countArgs []interface{} var countArgs []interface{}
if search != "" { if search != "" {
@ -32,15 +56,15 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
} }
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, 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), COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
IFNULL(pc.code, ''), IFNULL(c.code, '') IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE 1=1` WHERE 1=1` + stockedMaterialFilterSQL
var args []interface{} var args []interface{}
if search != "" { if search != "" {
@ -61,7 +85,7 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
var stocks []model.Stock var stocks []model.Stock
for rows.Next() { for rows.Next() {
var s model.Stock var s model.Stock
err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -70,20 +94,22 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
return stocks, total, nil return stocks, total, nil
} }
// GetByMaterialID 返回单个物料的库存。这里刻意不套 stockedMaterialFilterSQL:物料申请和
// 出库表单是按物料逐个查询的,无论有没有入过库都必须给出答案。
func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) { func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) {
s := &model.Stock{} s := &model.Stock{}
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, 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), COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
IFNULL(pc.code, ''), IFNULL(c.code, '') IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ? WHERE m.id = ?
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code` 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.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,9 +122,10 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
WHERE 1=1` + stockedMaterialFilterSQL + `
GROUP BY m.id GROUP BY m.id
HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0) ` + stockWarningHavingSQL + `
) warning_materials` ) warning_materials`
var total int64 var total int64
err := database.DB.QueryRow(countQuery).Scan(&total) err := database.DB.QueryRow(countQuery).Scan(&total)
@ -107,16 +134,17 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
} }
query := `SELECT m.id, m.material_code, m.name, m.spec, m.unit, 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), COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
IFNULL(pc.code, ''), IFNULL(c.code, '') IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE 1=1` + stockedMaterialFilterSQL + `
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code 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) ` + stockWarningHavingSQL + `
ORDER BY m.id LIMIT ? OFFSET ?` ORDER BY m.id LIMIT ? OFFSET ?`
rows, err := database.DB.Query(query, limit, offset) rows, err := database.DB.Query(query, limit, offset)
if err != nil { if err != nil {
@ -127,7 +155,7 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er
var stocks []model.Stock var stocks []model.Stock
for rows.Next() { for rows.Next() {
var s model.Stock var s model.Stock
err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.MajorCategory, &s.MinorCategory) err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.TotalOnHand, &s.WarningStock, &s.WarningEnabled, &s.MajorCategory, &s.MinorCategory)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -342,15 +370,18 @@ func (r *StockRepo) UpdateStockTx(tx *sql.Tx, materialID int64, delta decimal.De
return nil return nil
} }
// UpdateWarningStock stores the default warehouse threshold in the inventory domain. const defaultWarehouseExistsSQL = `SELECT COUNT(*) FROM organizations o
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' JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT'
WHERE o.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) // execQuerier 同时被 *sql.DB 和 *sql.Tx 满足,让预警阈值的写入逻辑可以在入库事务里复用。
type execQuerier interface {
Exec(query string, args ...interface{}) (sql.Result, error)
QueryRow(query string, args ...interface{}) *sql.Row
}
func upsertStockWarningRule(q execQuerier, materialID int64, minimumAvailable decimal.Decimal, enabled bool) error {
result, err := q.Exec(upsertStockWarningRuleSQL, materialID, minimumAvailable, enabled)
if err != nil { if err != nil {
return err return err
} }
@ -359,11 +390,30 @@ func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decima
return err return err
} }
if affected == 0 { if affected == 0 {
return fmt.Errorf("default organization or warehouse is missing") // MySQL 在两种情况下都返回 0 影响行:语句什么都没插入(找不到默认组织/仓库),
// 以及传入的值和已存的行完全一致。所以要先区分清楚再决定是否报错。
var exists int
if err := q.QueryRow(defaultWarehouseExistsSQL).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return fmt.Errorf("default organization or warehouse is missing")
}
} }
return nil return nil
} }
// UpdateWarningStock 把默认仓库的预警阈值写入库存域。
func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal, enabled bool) error {
return upsertStockWarningRule(database.DB, materialID, minimumAvailable, enabled)
}
// UpdateWarningStockTx 在入库事务内写入预警阈值,保证物料的第一个阈值和它的第一行库存
// 同时落库。
func (r *StockRepo) UpdateWarningStockTx(tx *sql.Tx, materialID int64, minimumAvailable decimal.Decimal, enabled bool) error {
return upsertStockWarningRule(tx, materialID, minimumAvailable, enabled)
}
func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error { func (r *StockRepo) DeleteLogs(orderType string, orderID int64) error {
query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?` query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?`
_, err := database.DB.Exec(query, orderType, orderID) _, err := database.DB.Exec(query, orderType, orderID)

View File

@ -10,12 +10,12 @@ import (
) )
const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.unit, const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), TRUE),
IFNULL(pc.code, ''), IFNULL(c.code, '') IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
LEFT JOIN material_categories c ON c.id = m.category_id LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ? WHERE m.id = ?
@ -33,8 +33,8 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
database.DB = db database.DB = db
mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows( mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "major_category", "minor_category"}). sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category"}).
AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", "B01", "CAT-SUB-027"), AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", true, "B01", "CAT-SUB-027"),
) )
stock, err := NewStockRepo().GetByMaterialID(9) stock, err := NewStockRepo().GetByMaterialID(9)
@ -44,6 +44,9 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) {
if !stock.TotalOnHand.Equal(decimal.RequireFromString("12.500000")) || !stock.WarningStock.Equal(decimal.NewFromInt(3)) { if !stock.TotalOnHand.Equal(decimal.RequireFromString("12.500000")) || !stock.WarningStock.Equal(decimal.NewFromInt(3)) {
t.Fatalf("unexpected stock quantities: %+v", stock) t.Fatalf("unexpected stock quantities: %+v", stock)
} }
if !stock.WarningEnabled {
t.Fatalf("expected warning to read back as enabled: %+v", stock)
}
if stock.MajorCategory != "B01" || stock.MinorCategory != "CAT-SUB-027" { if stock.MajorCategory != "B01" || stock.MinorCategory != "CAT-SUB-027" {
t.Fatalf("unexpected stock categories: %+v", stock) t.Fatalf("unexpected stock categories: %+v", stock)
} }
@ -121,18 +124,134 @@ func TestStockRepoUpdateWarningStockWritesInventoryRule(t *testing.T) {
}) })
database.DB = db database.DB = db
query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled) mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
SELECT o.id, w.id, ?, ?, TRUE WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(1, 1))
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 { if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err != nil {
t.Fatalf("update stock warning: %v", err) t.Fatalf("update stock warning: %v", err)
} }
if err := mock.ExpectationsWereMet(); err != nil { if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
// 重复保存同一个阈值时 MySQL 返回 0 影响行,这跟"找不到默认组织/仓库"是两种不同情况,
// 不能把它当成错误报出来。
func TestStockRepoUpdateWarningStockKeepsUnchangedThreshold(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.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(defaultWarehouseExistsSQL)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err != nil {
t.Fatalf("unchanged threshold must not fail: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestStockRepoUpdateWarningStockReportsMissingDefaultWarehouse(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.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(defaultWarehouseExistsSQL)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err == nil {
t.Fatal("expected an error when the default organization/warehouse is missing")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 库存列表和它的 COUNT 查询是两条独立 SQL,筛选条件必须同时出现在两边,否则分页总数
// 会和实际行数对不上、末尾多出空白页。
func TestStockRepoGetAllStockFiltersBothCountAndList(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
filter := regexp.QuoteMeta(stockedMaterialFilterSQL)
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m WHERE 1=1.*` + filter).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+filter+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category",
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, "A01", "A01-01"))
stocks, total, err := NewStockRepo().GetAllStock("", 20, 0)
if err != nil {
t.Fatalf("get all stock: %v", err)
}
if total != 1 || len(stocks) != 1 {
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
}
if !stocks[0].WarningStock.Equal(decimal.NewFromInt(10)) || !stocks[0].WarningEnabled {
t.Fatalf("unexpected warning fields: %+v", stocks[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 预警列表同样有 COUNT 和主查询两条 SQL,而且两条都必须带上与 service.ClassifyStock
// 对应的 HAVING 判定。
func TestStockRepoGetWarningStockAppliesThresholdRuleToBothQueries(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
having := regexp.QuoteMeta(stockWarningHavingSQL)
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM \(.*` + having).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+having+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "major_category", "minor_category",
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "0.000000", "10.000000", true, "A01", "A01-01"))
stocks, total, err := NewStockRepo().GetWarningStock(20, 0)
if err != nil {
t.Fatalf("get warning stock: %v", err)
}
if total != 1 || len(stocks) != 1 {
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -70,6 +70,7 @@ func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategor
func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) error { func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) error {
item.Code = strings.TrimSpace(item.Code) item.Code = strings.TrimSpace(item.Code)
item.Name = strings.TrimSpace(item.Name) item.Name = strings.TrimSpace(item.Name)
item.Description = strings.TrimSpace(item.Description)
if item.Code == "" || item.Name == "" { if item.Code == "" || item.Name == "" {
return errInvalidMasterData return errInvalidMasterData
} }

View File

@ -3,3 +3,5 @@ package service
import "errors" import "errors"
var ErrMaterialNotFound = errors.New("material not found") var ErrMaterialNotFound = errors.New("material not found")
var ErrInvalidWarningStock = errors.New("warning stock must be greater than or equal to 0")

View File

@ -39,6 +39,9 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
if !exists { if !exists {
return nil, ErrMaterialNotFound return nil, ErrMaterialNotFound
} }
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
order.Items[i] = model.InboundItem{ order.Items[i] = model.InboundItem{
MaterialID: item.MaterialID, MaterialID: item.MaterialID,
Quantity: item.Quantity, Quantity: item.Quantity,
@ -65,7 +68,9 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
// Update stock and create logs within transaction // Update stock and create logs within transaction
for i := range order.Items { for i := range order.Items {
item := &order.Items[i] item := &order.Items[i]
material, err := s.materialSvc.GetByID(item.MaterialID) // 这里必须用赋值而不是 := ,否则 err 会被遮蔽,defer 里的回滚判断看不到它。
var material *model.Material
material, err = s.materialSvc.GetByID(item.MaterialID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -77,6 +82,15 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound
return nil, err return nil, err
} }
// 入库明细上带的预警阈值在同一个事务里写入,保证物料的第一个阈值和第一行库存
// 同时落库。阈值为 nil 表示本次不改动已存的值。
if warningStock := req.Items[i].WarningStock; warningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *warningStock, true)
if err != nil {
return nil, err
}
}
stockLog := &model.StockLog{ stockLog := &model.StockLog{
MaterialID: item.MaterialID, MaterialID: item.MaterialID,
Type: "in", Type: "in",
@ -164,6 +178,13 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
return nil, err return nil, err
} }
// 阈值校验放在事务之外,避免为非法输入白开一个事务
for _, item := range req.Items {
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
}
// Start transaction // Start transaction
tx, err := repository.BeginTx() tx, err := repository.BeginTx()
if err != nil { if err != nil {
@ -210,6 +231,15 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod
return nil, err return nil, err
} }
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
// 阈值覆盖回旧数。
if item.WarningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, item.MaterialID, *item.WarningStock, true)
if err != nil {
return nil, err
}
}
stockLog := &model.StockLog{ stockLog := &model.StockLog{
MaterialID: item.MaterialID, MaterialID: item.MaterialID,
Type: "in", Type: "in",

View File

@ -18,20 +18,57 @@ func NewStockService() *StockService {
} }
} }
// ClassifyStock 与 repository.stockWarningHavingSQL 是同一套规则,后者负责筛出预警列表,
// 两者必须保持一致:只有配置了启用状态且大于 0 的预警阈值、并且现存量已经触达该阈值时
// 才是 low。
//
// 这里刻意没有独立的"已耗尽"状态:没设过阈值的物料即使库存归零也只是 normal——那是操作
// 者自己选择不被告知,不是告警。
func ClassifyStock(totalOnHand, warningStock decimal.Decimal, warningEnabled bool) string {
if !warningEnabled || warningStock.LessThanOrEqual(decimal.Zero) {
return model.StockStatusNormal
}
if totalOnHand.LessThanOrEqual(warningStock) {
return model.StockStatusLow
}
return model.StockStatusNormal
}
func classifyAll(stocks []model.Stock) {
for i := range stocks {
stocks[i].StockStatus = ClassifyStock(stocks[i].TotalOnHand, stocks[i].WarningStock, stocks[i].WarningEnabled)
}
}
func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) { func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
return s.stockRepo.GetAllStock(search, limit, offset) stocks, total, err := s.stockRepo.GetAllStock(search, limit, offset)
if err != nil {
return nil, 0, err
}
classifyAll(stocks)
return stocks, total, nil
} }
func (s *StockService) GetByMaterialID(materialID int64) (*model.Stock, error) { func (s *StockService) GetByMaterialID(materialID int64) (*model.Stock, error) {
return s.stockRepo.GetByMaterialID(materialID) stock, err := s.stockRepo.GetByMaterialID(materialID)
if err != nil {
return nil, err
}
stock.StockStatus = ClassifyStock(stock.TotalOnHand, stock.WarningStock, stock.WarningEnabled)
return stock, nil
} }
func (s *StockService) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) { func (s *StockService) GetWarningStock(limit, offset int) ([]model.Stock, int64, error) {
return s.stockRepo.GetWarningStock(limit, offset) stocks, total, err := s.stockRepo.GetWarningStock(limit, offset)
if err != nil {
return nil, 0, err
}
classifyAll(stocks)
return stocks, total, nil
} }
func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal) error { func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal, enabled bool) error {
return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable) return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable, enabled)
} }
func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) { func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) {

View File

@ -0,0 +1,47 @@
package service
import (
"testing"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
)
// ClassifyStock 是库存状态的唯一判定入口,这张表同时钉住"新增物料不误报"和
// "真的低于阈值要报警"两侧的边界。它必须与 repository.stockWarningHavingSQL 一致。
func TestClassifyStockBoundaries(t *testing.T) {
cases := []struct {
name string
totalOnHand string
warningStock string
warningEnabled bool
want string
}{
{"用完且没设过预警值", "0", "0", true, model.StockStatusNormal},
{"有货且没设过预警值", "5", "0", true, model.StockStatusNormal},
{"设了阈值但库存归零", "0", "10", true, model.StockStatusLow},
{"低于阈值", "5", "10", true, model.StockStatusLow},
{"恰好触达阈值即预警", "10", "10", true, model.StockStatusLow},
{"略高于阈值", "10.000001", "10", true, model.StockStatusNormal},
{"高于阈值", "15", "10", true, model.StockStatusNormal},
{"用完但手动关闭预警", "0", "10", false, model.StockStatusNormal},
{"低于阈值但手动关闭预警", "5", "10", false, model.StockStatusNormal},
{"有货且手动关闭预警", "15", "10", false, model.StockStatusNormal},
// 数量是 decimal 字符串,按字典序比较会把 "370" 判成小于 "50"。
{"大数按数值而非字典序比较", "370.000000", "50.000000", true, model.StockStatusNormal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ClassifyStock(
decimal.RequireFromString(tc.totalOnHand),
decimal.RequireFromString(tc.warningStock),
tc.warningEnabled,
)
if got != tc.want {
t.Fatalf("ClassifyStock(%s, %s, %v) = %s, want %s",
tc.totalOnHand, tc.warningStock, tc.warningEnabled, got, tc.want)
}
})
}
}

View File

@ -1,4 +1,7 @@
-- WMMS fresh schema for MySQL 8.0. Executed once for an empty Docker volume. -- WMMS fresh schema for MySQL 8.0. Executed once for an empty Docker volume.
-- SET NAMES must come first: the mysql client that runs this file may default to
-- latin1, which would mangle every Chinese literal below into mojibake.
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS wmms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS wmms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE wmms; USE wmms;
@ -11,7 +14,7 @@ CREATE TABLE user_roles (user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, creat
CREATE TABLE user_organizations (user_id BIGINT NOT NULL, organization_id BIGINT NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,organization_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE user_organizations (user_id BIGINT NOT NULL, organization_id BIGINT NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,organization_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, expires_at DATETIME NOT NULL, revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_revoked_tokens_expires_at(expires_at), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, expires_at DATETIME NOT NULL, revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_revoked_tokens_expires_at(expires_at), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_types (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_types_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE material_types (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_types_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, parent_id BIGINT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_categories_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(1000) NOT NULL DEFAULT '', parent_id BIGINT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_categories_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE units (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, precision_scale TINYINT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_units_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE units (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, precision_scale TINYINT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_units_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE materials (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_code VARCHAR(50) NOT NULL, name VARCHAR(200) NOT NULL, material_image VARCHAR(500), spec VARCHAR(200), manufacturer_model VARCHAR(200), material_type_id BIGINT, category_id BIGINT NOT NULL, base_unit_id BIGINT, unit VARCHAR(20), status VARCHAR(20) NOT NULL DEFAULT 'active', version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_materials_org_code(organization_id,material_code), KEY idx_materials_category(category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(material_type_id) REFERENCES material_types(id) ON DELETE RESTRICT, FOREIGN KEY(category_id) REFERENCES material_categories(id) ON DELETE RESTRICT, FOREIGN KEY(base_unit_id) REFERENCES units(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE materials (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_code VARCHAR(50) NOT NULL, name VARCHAR(200) NOT NULL, material_image VARCHAR(500), spec VARCHAR(200), manufacturer_model VARCHAR(200), material_type_id BIGINT, category_id BIGINT NOT NULL, base_unit_id BIGINT, unit VARCHAR(20), status VARCHAR(20) NOT NULL DEFAULT 'active', version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_materials_org_code(organization_id,material_code), KEY idx_materials_category(category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(material_type_id) REFERENCES material_types(id) ON DELETE RESTRICT, FOREIGN KEY(category_id) REFERENCES material_categories(id) ON DELETE RESTRICT, FOREIGN KEY(base_unit_id) REFERENCES units(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, category_id BIGINT NOT NULL, prefix VARCHAR(30) NOT NULL, sequence_length TINYINT NOT NULL, next_sequence BIGINT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_code_rules_org_category(organization_id,category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(category_id) REFERENCES material_categories(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, category_id BIGINT NOT NULL, prefix VARCHAR(30) NOT NULL, sequence_length TINYINT NOT NULL, next_sequence BIGINT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_code_rules_org_category(organization_id,category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(category_id) REFERENCES material_categories(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -39,20 +42,57 @@ INSERT INTO roles (code,name) VALUES ('super_admin','超级管理员'),('warehou
INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库'); INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库');
INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r CROSS JOIN permissions p WHERE r.code='super_admin'; INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r CROSS JOIN permissions p WHERE r.code='super_admin';
INSERT INTO material_types (organization_id,code,name) VALUES (1,'raw_material','原材料'),(1,'semi_finished','半成品'),(1,'finished_good','成品'),(1,'consumable','耗材'),(1,'service','服务'); INSERT INTO material_types (organization_id,code,name) VALUES (1,'raw_material','原材料'),(1,'semi_finished','半成品'),(1,'finished_good','成品'),(1,'consumable','耗材'),(1,'service','服务');
INSERT INTO material_categories (organization_id,code,name) VALUES (1,'A01','标准件'),(1,'A02','成品件'),(1,'A03','简易加工件'),(1,'A04','精密加工件'),(1,'A05','钣金件'),(1,'A06','塑胶件'),(1,'A07','其他'),(1,'B01','外购件类'),(1,'B02','简易加工件'),(1,'B03','PCB硬板'),(1,'B04','FPC软板'),(1,'B05','线束'),(1,'B06','组装组件'),(1,'B07','其他'),(1,'C01','连接耗材'),(1,'C02','密封耗材'),(1,'C03','其他'),(1,'D01','自研程序固件'),(1,'D02','外购软件授权'),(1,'D03','授权介质'),(1,'D04','其他'),(1,'E01','成品'),(1,'E02','半成品'),(1,'F01','资料文档'),(1,'F02','标识标牌'),(1,'F03','包装物料'),(1,'F04','随机工具配件'),(1,'F05','其他'); -- Category tree: 6 majors (code A-F) + 28 subcategories (code A01-F05).
INSERT INTO material_categories (organization_id,code,name,parent_id,status) INSERT INTO material_categories (organization_id,code,name,description) VALUES
SELECT organization_id,CONCAT(code,'-01'),CONCAT(name,'·通用型'),id,status FROM material_categories WHERE parent_id IS NULL; (1,'A','机械结构类','遵循通用分级规则:零加工外购标准件 → 零加工外购成品件 → 外购原料简易加工 → 深度定制加工件 → 本类兜底。纯机械传动件纳入本类管理。'),
INSERT INTO material_categories (organization_id,code,name,parent_id,status) (1,'B','电气元器件类','遵循通用分级规则:零加工外购标准成品 → 外购原料简易加工 → 单项工艺定制加工 → 多工艺集成组装件 → 本类兜底。带电气接口、电控功能的电机、电动部件统一归入本类;外购卷材/长条电气件,现场裁剪使用的纳入简易加工子类。'),
SELECT organization_id,CONCAT(code,'-02'),CONCAT(name,'·专用型'),id,status FROM material_categories WHERE parent_id IS NULL; (1,'C','产品工艺耗材类','按使用场景划分,仅收录装配在设备内部、随产品出厂的专用耗材,全部为外购成品;车间通用清洁、劳保、运维耗材不纳入本类。'),
(1,'D','软件&固件&授权类','收录设备内置运行、随产品交付的程序、授权介质、使用许可,属于无形资产及配套实体载体,无物理加工环节。'),
(1,'E','半成品&成品类','按产品装配阶段划分,为多类零部件组合而成的装配体,是BOM顶层节点;仅区分半成品、成品两类。'),
(1,'F','包材类','本类统一收纳不参与设备核心功能运行、不属于机身核心的物料,仅作为产品配套、合规、交付附属物件。包含附着机身的标识、随机资料、整套包装物料、交付工具等配套物资,全部随整机出厂交付,不影响设备核心功能实现。');
INSERT INTO material_categories (organization_id,code,name,description) VALUES
(1,'A01','标准件','1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉、六角螺母、平垫圈、弹簧垫圈、防松垫片、六角铜螺柱。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。'),
(1,'A02','成品件','1.核心定义:工业标准成品件,整件采购到货,无需裁切、折弯、切削、焊接等二次加工,可直接装配使用。 2.包含说明:设备通用机械辅助成品、纯机械传动成品件,自带标准型号,无需图纸定制。 3.现场举例:脚轮、磁铁、设备防护罩、标准导轨、丝杆、轴承、同步轮、同步带。 4.边界区分:完整成品无需加工归A02;原材料需裁切加工归A03。'),
(1,'A03','简易加工件','1.核心定义:采购标准定尺长条原料,仅做断料、裁切、简单打孔,无车铣、折弯、焊接、异形精密深加工。 2.包含说明:机架、框架类基础原料件,仅做简单下料、简易开孔处理。 3.现场举例:工业铝型材、方管、圆管、槽钢、角钢、实心棒材裁切下料件。 4.边界区分:长条原料简易裁切归A03;成品整件直接使用=A02;异形精密切削成型=A04。'),
(1,'A04','精密加工件','1.核心定义:金属厚料基材,通过CNC车、铣、钻、镗、削精密成型的非标结构件,包含金属研发手板、非标传动结构件。 2.包含说明:异形结构、精密配合、运动连接、承重支撑类金属定制件。 3.现场举例:设备金属板、安装转接板、支撑柱、旋转支架、非标机械连杆、金属CNC手板。 4.边界区分:厚料、切削工艺归A04;金属薄板折弯、焊接成型归A05。'),
(1,'A05','钣金件','1.核心定义:金属薄板基材,通过折弯、冲压、焊接、开孔成型的板类结构件,包含钣金研发手板。 2.包含说明:设备外壳基材、层板、机架、屏蔽防护类钣金件。 3.现场举例:金属层板、屏蔽罩、钢架、侧板、钣金支架、薄板折弯手板。 4.边界区分:薄板基材、折弯/焊接/冲压为主工艺归A05;厚料异形精密切削工艺归A04。'),
(1,'A06','塑胶件','1.核心定义:纯塑胶材质结构件、外观件,涵盖注塑量产件、3D打印/CNC/复模研发手板。 2.包含说明:塑胶防护、外观、支撑、壳体类零件,不区分生产工艺与阶段。 3.现场举例:灯罩、灯带支座、设备外壳、扶手蒙皮、塑胶3D打印手板。 4.边界区分:所有纯塑胶结构件统一归此类;手板与量产件通过系统属性标注区分。'),
(1,'A07','其他','1.核心定义:以上子类无法精准归类的小众机械零件。 2.包含说明:冷门机械辅件、非标小连接件。 3.现场举例:碟形弹簧、小型非标结构件。 4.边界区分:A01-A06无法匹配的机械类物料,全部归入此类。'),
(1,'B01','成品件','1.核心定义:市面标准电气成品、模块、电动部件,整件采购,无需出图、加工、组装,可直接装配。 2.包含说明:开关、电源、端子、标准线材、工控模块、各类电机及电动执行件。 3.现场举例:急停按钮、船型开关、温控开关、磁环、接线端子、冷压端子、开关电源、UPS、触摸显示器、工控机、IO扩展模块、标准网线、串口线、伺服电机、步进电机。 4.边界区分:无定制、无加工的标准电气成品、电动件全部归此类;需现场裁剪的长条电气件归入B02。'),
(1,'B02','简易加工件','1.核心定义:采购长条、卷材类标准电气原料,到货后仅做裁剪、分段等简单加工,无焊接、改板、接线等深度作业。 2.包含说明:通用长条灯条、连续式灯带、长距离线缆等,按装配尺寸现场裁切使用。 3.现场举例:整机外购标准灯条、长条形灯带,根据安装长度裁剪分段。 4.边界区分:仅做裁剪、分段的长条/卷材电气原料归B02;完整成品电气件归B01;需制板、压接定制的物料归入后续子类。'),
(1,'B03','PCB硬板','1.核心定义:依据我方PCB图纸,外协加工的硬质电路板,含贴片、插件成品板。 2.包含说明:自研主控板、驱动板、接口板等硬质功能板卡。 3.现场举例:主控灯带控制板、从控灯带控制板、各类自研PCB板卡。 4.边界区分:仅单纯PCB板加工,无外部其他器件装配。'),
(1,'B04','FPC软板','1.核心定义:依据图纸,外协定制的FPC软板、柔性排线。 2.包含说明:设备活动连接、弯折部位专用柔性电路件。 3.现场举例:设备关节连接软板、显示模组柔性排线。 4.边界区分:柔性线路板归B04;硬质PCB板归入B03。'),
(1,'B05','线束','1.核心定义:依据线表、线序、长度要求,外协裁切、压接、编组的专用非标线束。 2.包含说明:设备专用成套线缆、定制线组。 3.现场举例:定制线缆、LM系列专用线束。 4.边界区分:纯线材外协压接编组加工,无板卡、器件组装。'),
(1,'B06','组装组件','1.核心定义:我方提供图纸、电路图、BOM、装配工艺,外协完成板卡、线束、电气件整体组装、接线、调试后整件来料的集成组件。 2.包含说明:PCB、软板、线材、电动件组合而成的集成电气装配体。 3.现场举例:PCB板+柔性线路板+电机+配套线材集成组件。 4.边界区分:多品类电气件集成装配体归此类;单一板卡、单一线束分别归入对应子类。'),
(1,'B07','其他','1.核心定义:以上子类无法归类的小众电气配件、特殊电子元件。 2.包含说明:冷门电气辅件、非主流功能元件。 3.现场举例:B01-B06无法匹配的电气类物料,统一归入此类。'),
(1,'C01','连接耗材','1.核心定义:设备接线、装配过程使用,最终留存于产品内部的辅材。 2.包含说明:线路防护、绝缘、粘接类耗材。 3.现场举例:热缩管、梅花管、绝缘护套、设备专用粘接剂。 4.边界区分:仅产品内置装配耗材归此类。'),
(1,'C02','密封耗材','1.核心定义:用于设备密封、防尘、防水,装配后随产品交付的耗材。 2.包含说明:各类密封垫圈、专用密封油脂、防护填充材料。 3.现场举例:设备密封胶圈、腔体密封脂。 4.边界区分:设备本体密封耗材归此类;生产设备运维耗材不纳入。'),
(1,'C03','其他','1.核心定义:以上子类无法归类、随产品出厂的小众工艺耗材。 2.包含说明:本类仅收纳产品内置耗材,通用车间耗材一律排除。'),
(1,'D01','自研程序固件','1.核心定义:企业自主开发,嵌入设备硬件、保障设备核心运行的嵌入式固件、上位机控制程序。 2.包含说明:设备下位机固件、定制控制程序、专用驱动程序。 3.现场举例:设备主控固件、人机界面程序、运动控制程序。 4.边界区分:自主研发、设备必备的程序归此类;办公、设计类软件不纳入。'),
(1,'D02','外购软件授权','1.核心定义:外部采购,设备运行必需的第三方商用软件、正版使用许可。 2.包含说明:设备配套第三方控制软件、功能组件、永久/阶段性使用授权。 3.现场举例:第三方算法软件、配套组态软件、软件使用授权证书。 4.边界区分:参与设备功能实现的外购软件及授权归此类。'),
(1,'D03','授权介质','1.核心定义:承载软件、设备授权的实物硬件,随整机配套交付。 2.包含说明:软件加密锁、设备身份密钥等硬件载体。 3.现场举例:软件加密狗、专用授权密钥硬件。 4.边界区分:仅产品配套的授权硬件归此类。'),
(1,'D04','其他','1.核心定义:以上子类无法归类的配套程序组件、临时授权、辅助文档。 2.包含说明:软件、授权相关杂项统一归入此类。'),
(1,'E01','成品','1.核心定义:完成全部装配、调试、检验,可直接对外交付的有源医疗器械整机。 2.包含说明:公司主营各类完整诊疗设备。 3.现场举例:眼科手术控制系统、主控制台、从控制台整机。 4.边界区分:最终交付整机产品归此类。'),
(1,'E02','半成品','1.核心定义:由多类零部件装配而成,未完成最终整机装配,可流转至下道工序继续加工的中间组件。 2.包含说明:工序级装配体、模块级组合组件。 3.现场举例:控制台外壳组件、底座组件、运动机构组件、电子集成组件。 4.边界区分:非最终整机的组合装配体归此类。'),
(1,'F01','资料文档','1.核心定义:随整机配套交付的纸质/合规类技术文档、资质文件。 2.包含说明:产品使用、安装、维修、合规备案所需配套资料。 3.现场举例:产品说明书、操作手册、安装手册、维修手册、出厂合格证、检测报告。 4.边界区分:仅产品随货交付的正式资料文件归入此类;内部研发工艺资料不纳入BOM。'),
(1,'F02','标识标牌','1.核心定义:附着、固定在设备机身表面,用于合规标示、参数说明、警示提示的标识类物件。 2.包含说明:设备铭牌、参数标签、警示标贴、功能标识、规格标牌。 3.现场举例:机身金属铭牌、设备参数贴纸、安全警示标签、接口标识贴、合规标识牌。 4.边界区分:所有贴附/固定在产品本体的标识类物件统一归入此类,不再归入机械类。'),
(1,'F03','包装物料','1.核心定义:产品整机交付使用的外包装箱体、外壳及缓冲防护、填充类配套物料。 2.包含说明:定制航空箱、外包装箱体、防护外箱、内衬框架、缓冲泡棉、填充棉、防护内衬、减震包装辅材。 3.现场举例:设备定制航空箱、整机外包装箱、成型泡棉内衬、定位缓冲泡棉、防震填充辅料。 4.边界区分:所有产品专属包装、防护、减震填充物料全部归入此类。'),
(1,'F04','随机工具配件','1.核心定义:随产品整机交付给客户,供客户安装、调试、日常运维、拆装使用的小型工具、配套配件。 2.包含说明:设备专属交付工具、配套小配件,非设备本体结构件。 3.现场举例:随机内六角扳手、专用拆装工具、配套备用小配件。 4.边界区分:随货交付客户的工具归入此类;车间生产/自检自用工具、工装治具不纳入本BOM体系。'),
(1,'F05','其他','1.核心定义:以上子类无法归类、随产品交付的其余附属配套物料。 2.包含说明:本大类无对应子类的产品配套杂项,统一兜底归入。');
-- Subcategory codes are 3 chars (A01); their first letter is the major's code.
UPDATE material_categories AS c
JOIN material_categories AS p ON p.organization_id = c.organization_id AND p.code = LEFT(c.code, 1)
SET c.parent_id = p.id
WHERE c.organization_id = 1 AND CHAR_LENGTH(c.code) = 3 AND c.parent_id IS NULL;
-- Material number = subcategory code + 4-digit sequence; next_sequence is
-- "current max sequence + 1", so the next codes are A010201 / A020202 / B010203.
INSERT INTO code_rules (organization_id,category_id,prefix,sequence_length,next_sequence,status)
SELECT 1,c.id,c.code,4,CASE c.code WHEN 'A02' THEN 202 WHEN 'B01' THEN 203 ELSE 201 END,'active'
FROM material_categories c WHERE c.organization_id = 1 AND c.parent_id IS NOT NULL;
INSERT INTO warehouses (id,organization_id,code,name,status) VALUES (1,1,'DEFAULT','默认仓库','active'); INSERT INTO warehouses (id,organization_id,code,name,status) VALUES (1,1,'DEFAULT','默认仓库','active');
INSERT INTO warehouse_locations (id,warehouse_id,code,name,status) VALUES (1,1,'MAIN','主库位','active'); INSERT INTO warehouse_locations (id,warehouse_id,code,name,status) VALUES (1,1,'MAIN','主库位','active');
-- The repository is maintained on Windows. Normalize the UTF-8 seed literals -- Seed encoding is handled by SET NAMES utf8mb4 at the top of this file. The
-- after MySQL imports this file so they remain correct regardless of client codepage. -- previous latin1/BINARY round-trip repair is gone: it only produced correct
UPDATE organizations SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4); -- text when the client was latin1, and it corrupts (or destroys non-latin1
UPDATE roles SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4); -- characters such as "→") when the client is already utf8mb4.
UPDATE permissions SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4);
UPDATE material_types SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4);
UPDATE material_categories SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4);
UPDATE warehouses SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4);
UPDATE warehouse_locations SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4);

View File

@ -0,0 +1,151 @@
-- ============================================================================
-- 物料分类重建脚本(material_categories + code_rules)
--
-- 用途:把 material_categories 全量替换为 6 个大类 + 28 个子类,新增
-- description(说明)字段,并同步写入 code_rules 编码规则。
--
-- !! 破坏性操作,执行前请务必备份数据库 !!
--
-- materials.category_id 的外键是 ON DELETE RESTRICT,物料不先清理就无法
-- 删除分类。因此本脚本会连带清空以下表:
-- material_categories / materials / code_rules / inbound_orders /
-- inbound_items / outbound_orders / outbound_items / inventory_balances /
-- stock_logs / stock_warning_rules / material_logs / tags /
-- material_suppliers / material_unit_conversions /
-- material_applications / application_items / apply_orders / apply_items
-- 物料、库存、出入库单据与流水将全部丢失,不可恢复。
--
-- 执行方式:
-- mysql -uroot -p --default-character-set=utf8mb4 wmms < scripts/update_material_categories.sql
--
-- 字符集:统一 utf8mb4。脚本开头 SET NAMES utf8mb4 确保中文正确落库,
-- 不依赖客户端默认字符集。
-- 幂等性:可重复执行(建列有存在性判断,其余为清空后重建)。
-- ============================================================================
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------------
-- 1. 新增说明字段 description
-- ---------------------------------------------------------------------------
SET @ddl := (SELECT IF(
COUNT(*) = 0,
'ALTER TABLE material_categories ADD COLUMN description VARCHAR(1000) NOT NULL DEFAULT '''' AFTER name',
'SELECT 1'
) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'material_categories'
AND COLUMN_NAME = 'description');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 清空存量数据
-- 顺序:先清引用方,再清被引用方;分类表先删子再删父(自引用外键 RESTRICT)。
-- ---------------------------------------------------------------------------
DELETE FROM application_items;
DELETE FROM apply_items;
DELETE FROM inbound_items;
DELETE FROM outbound_items;
DELETE FROM material_suppliers;
DELETE FROM material_unit_conversions;
DELETE FROM stock_warning_rules;
DELETE FROM inventory_balances;
DELETE FROM stock_logs;
DELETE FROM tags;
DELETE FROM material_logs;
DELETE FROM materials;
DELETE FROM inbound_orders;
DELETE FROM outbound_orders;
DELETE FROM material_applications;
DELETE FROM apply_orders;
DELETE FROM code_rules;
DELETE FROM material_categories WHERE parent_id IS NOT NULL;
DELETE FROM material_categories WHERE parent_id IS NULL;
ALTER TABLE materials AUTO_INCREMENT = 1;
ALTER TABLE material_categories AUTO_INCREMENT = 1;
-- ---------------------------------------------------------------------------
-- 3. 写入 6 个大类
-- ---------------------------------------------------------------------------
INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES
(1, 'A', '机械结构类', '遵循通用分级规则:零加工外购标准件 → 零加工外购成品件 → 外购原料简易加工 → 深度定制加工件 → 本类兜底。纯机械传动件纳入本类管理。', NULL, 'active'),
(1, 'B', '电气元器件类', '遵循通用分级规则:零加工外购标准成品 → 外购原料简易加工 → 单项工艺定制加工 → 多工艺集成组装件 → 本类兜底。带电气接口、电控功能的电机、电动部件统一归入本类;外购卷材/长条电气件,现场裁剪使用的纳入简易加工子类。', NULL, 'active'),
(1, 'C', '产品工艺耗材类', '按使用场景划分,仅收录装配在设备内部、随产品出厂的专用耗材,全部为外购成品;车间通用清洁、劳保、运维耗材不纳入本类。', NULL, 'active'),
(1, 'D', '软件&固件&授权类', '收录设备内置运行、随产品交付的程序、授权介质、使用许可,属于无形资产及配套实体载体,无物理加工环节。', NULL, 'active'),
(1, 'E', '半成品&成品类', '按产品装配阶段划分,为多类零部件组合而成的装配体,是BOM顶层节点;仅区分半成品、成品两类。', NULL, 'active'),
(1, 'F', '包材类', '本类统一收纳不参与设备核心功能运行、不属于机身核心的物料,仅作为产品配套、合规、交付附属物件。包含附着机身的标识、随机资料、整套包装物料、交付工具等配套物资,全部随整机出厂交付,不影响设备核心功能实现。', NULL, 'active');
-- ---------------------------------------------------------------------------
-- 4. 写入 28 个子类(parent_id 先留空,第 5 步统一回填)
-- ---------------------------------------------------------------------------
INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES
(1, 'A01', '标准件', '1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉、六角螺母、平垫圈、弹簧垫圈、防松垫片、六角铜螺柱。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。', NULL, 'active'),
(1, 'A02', '成品件', '1.核心定义:工业标准成品件,整件采购到货,无需裁切、折弯、切削、焊接等二次加工,可直接装配使用。 2.包含说明:设备通用机械辅助成品、纯机械传动成品件,自带标准型号,无需图纸定制。 3.现场举例:脚轮、磁铁、设备防护罩、标准导轨、丝杆、轴承、同步轮、同步带。 4.边界区分:完整成品无需加工归A02;原材料需裁切加工归A03。', NULL, 'active'),
(1, 'A03', '简易加工件', '1.核心定义:采购标准定尺长条原料,仅做断料、裁切、简单打孔,无车铣、折弯、焊接、异形精密深加工。 2.包含说明:机架、框架类基础原料件,仅做简单下料、简易开孔处理。 3.现场举例:工业铝型材、方管、圆管、槽钢、角钢、实心棒材裁切下料件。 4.边界区分:长条原料简易裁切归A03;成品整件直接使用=A02;异形精密切削成型=A04。', NULL, 'active'),
(1, 'A04', '精密加工件', '1.核心定义:金属厚料基材,通过CNC车、铣、钻、镗、削精密成型的非标结构件,包含金属研发手板、非标传动结构件。 2.包含说明:异形结构、精密配合、运动连接、承重支撑类金属定制件。 3.现场举例:设备金属板、安装转接板、支撑柱、旋转支架、非标机械连杆、金属CNC手板。 4.边界区分:厚料、切削工艺归A04;金属薄板折弯、焊接成型归A05。', NULL, 'active'),
(1, 'A05', '钣金件', '1.核心定义:金属薄板基材,通过折弯、冲压、焊接、开孔成型的板类结构件,包含钣金研发手板。 2.包含说明:设备外壳基材、层板、机架、屏蔽防护类钣金件。 3.现场举例:金属层板、屏蔽罩、钢架、侧板、钣金支架、薄板折弯手板。 4.边界区分:薄板基材、折弯/焊接/冲压为主工艺归A05;厚料异形精密切削工艺归A04。', NULL, 'active'),
(1, 'A06', '塑胶件', '1.核心定义:纯塑胶材质结构件、外观件,涵盖注塑量产件、3D打印/CNC/复模研发手板。 2.包含说明:塑胶防护、外观、支撑、壳体类零件,不区分生产工艺与阶段。 3.现场举例:灯罩、灯带支座、设备外壳、扶手蒙皮、塑胶3D打印手板。 4.边界区分:所有纯塑胶结构件统一归此类;手板与量产件通过系统属性标注区分。', NULL, 'active'),
(1, 'A07', '其他', '1.核心定义:以上子类无法精准归类的小众机械零件。 2.包含说明:冷门机械辅件、非标小连接件。 3.现场举例:碟形弹簧、小型非标结构件。 4.边界区分:A01-A06无法匹配的机械类物料,全部归入此类。', NULL, 'active'),
(1, 'B01', '成品件', '1.核心定义:市面标准电气成品、模块、电动部件,整件采购,无需出图、加工、组装,可直接装配。 2.包含说明:开关、电源、端子、标准线材、工控模块、各类电机及电动执行件。 3.现场举例:急停按钮、船型开关、温控开关、磁环、接线端子、冷压端子、开关电源、UPS、触摸显示器、工控机、IO扩展模块、标准网线、串口线、伺服电机、步进电机。 4.边界区分:无定制、无加工的标准电气成品、电动件全部归此类;需现场裁剪的长条电气件归入B02。', NULL, 'active'),
(1, 'B02', '简易加工件', '1.核心定义:采购长条、卷材类标准电气原料,到货后仅做裁剪、分段等简单加工,无焊接、改板、接线等深度作业。 2.包含说明:通用长条灯条、连续式灯带、长距离线缆等,按装配尺寸现场裁切使用。 3.现场举例:整机外购标准灯条、长条形灯带,根据安装长度裁剪分段。 4.边界区分:仅做裁剪、分段的长条/卷材电气原料归B02;完整成品电气件归B01;需制板、压接定制的物料归入后续子类。', NULL, 'active'),
(1, 'B03', 'PCB硬板', '1.核心定义:依据我方PCB图纸,外协加工的硬质电路板,含贴片、插件成品板。 2.包含说明:自研主控板、驱动板、接口板等硬质功能板卡。 3.现场举例:主控灯带控制板、从控灯带控制板、各类自研PCB板卡。 4.边界区分:仅单纯PCB板加工,无外部其他器件装配。', NULL, 'active'),
(1, 'B04', 'FPC软板', '1.核心定义:依据图纸,外协定制的FPC软板、柔性排线。 2.包含说明:设备活动连接、弯折部位专用柔性电路件。 3.现场举例:设备关节连接软板、显示模组柔性排线。 4.边界区分:柔性线路板归B04;硬质PCB板归入B03。', NULL, 'active'),
(1, 'B05', '线束', '1.核心定义:依据线表、线序、长度要求,外协裁切、压接、编组的专用非标线束。 2.包含说明:设备专用成套线缆、定制线组。 3.现场举例:定制线缆、LM系列专用线束。 4.边界区分:纯线材外协压接编组加工,无板卡、器件组装。', NULL, 'active'),
(1, 'B06', '组装组件', '1.核心定义:我方提供图纸、电路图、BOM、装配工艺,外协完成板卡、线束、电气件整体组装、接线、调试后整件来料的集成组件。 2.包含说明:PCB、软板、线材、电动件组合而成的集成电气装配体。 3.现场举例:PCB板+柔性线路板+电机+配套线材集成组件。 4.边界区分:多品类电气件集成装配体归此类;单一板卡、单一线束分别归入对应子类。', NULL, 'active'),
(1, 'B07', '其他', '1.核心定义:以上子类无法归类的小众电气配件、特殊电子元件。 2.包含说明:冷门电气辅件、非主流功能元件。 3.现场举例:B01-B06无法匹配的电气类物料,统一归入此类。', NULL, 'active'),
(1, 'C01', '连接耗材', '1.核心定义:设备接线、装配过程使用,最终留存于产品内部的辅材。 2.包含说明:线路防护、绝缘、粘接类耗材。 3.现场举例:热缩管、梅花管、绝缘护套、设备专用粘接剂。 4.边界区分:仅产品内置装配耗材归此类。', NULL, 'active'),
(1, 'C02', '密封耗材', '1.核心定义:用于设备密封、防尘、防水,装配后随产品交付的耗材。 2.包含说明:各类密封垫圈、专用密封油脂、防护填充材料。 3.现场举例:设备密封胶圈、腔体密封脂。 4.边界区分:设备本体密封耗材归此类;生产设备运维耗材不纳入。', NULL, 'active'),
(1, 'C03', '其他', '1.核心定义:以上子类无法归类、随产品出厂的小众工艺耗材。 2.包含说明:本类仅收纳产品内置耗材,通用车间耗材一律排除。', NULL, 'active'),
(1, 'D01', '自研程序固件', '1.核心定义:企业自主开发,嵌入设备硬件、保障设备核心运行的嵌入式固件、上位机控制程序。 2.包含说明:设备下位机固件、定制控制程序、专用驱动程序。 3.现场举例:设备主控固件、人机界面程序、运动控制程序。 4.边界区分:自主研发、设备必备的程序归此类;办公、设计类软件不纳入。', NULL, 'active'),
(1, 'D02', '外购软件授权', '1.核心定义:外部采购,设备运行必需的第三方商用软件、正版使用许可。 2.包含说明:设备配套第三方控制软件、功能组件、永久/阶段性使用授权。 3.现场举例:第三方算法软件、配套组态软件、软件使用授权证书。 4.边界区分:参与设备功能实现的外购软件及授权归此类。', NULL, 'active'),
(1, 'D03', '授权介质', '1.核心定义:承载软件、设备授权的实物硬件,随整机配套交付。 2.包含说明:软件加密锁、设备身份密钥等硬件载体。 3.现场举例:软件加密狗、专用授权密钥硬件。 4.边界区分:仅产品配套的授权硬件归此类。', NULL, 'active'),
(1, 'D04', '其他', '1.核心定义:以上子类无法归类的配套程序组件、临时授权、辅助文档。 2.包含说明:软件、授权相关杂项统一归入此类。', NULL, 'active'),
(1, 'E01', '成品', '1.核心定义:完成全部装配、调试、检验,可直接对外交付的有源医疗器械整机。 2.包含说明:公司主营各类完整诊疗设备。 3.现场举例:眼科手术控制系统、主控制台、从控制台整机。 4.边界区分:最终交付整机产品归此类。', NULL, 'active'),
(1, 'E02', '半成品', '1.核心定义:由多类零部件装配而成,未完成最终整机装配,可流转至下道工序继续加工的中间组件。 2.包含说明:工序级装配体、模块级组合组件。 3.现场举例:控制台外壳组件、底座组件、运动机构组件、电子集成组件。 4.边界区分:非最终整机的组合装配体归此类。', NULL, 'active'),
(1, 'F01', '资料文档', '1.核心定义:随整机配套交付的纸质/合规类技术文档、资质文件。 2.包含说明:产品使用、安装、维修、合规备案所需配套资料。 3.现场举例:产品说明书、操作手册、安装手册、维修手册、出厂合格证、检测报告。 4.边界区分:仅产品随货交付的正式资料文件归入此类;内部研发工艺资料不纳入BOM。', NULL, 'active'),
(1, 'F02', '标识标牌', '1.核心定义:附着、固定在设备机身表面,用于合规标示、参数说明、警示提示的标识类物件。 2.包含说明:设备铭牌、参数标签、警示标贴、功能标识、规格标牌。 3.现场举例:机身金属铭牌、设备参数贴纸、安全警示标签、接口标识贴、合规标识牌。 4.边界区分:所有贴附/固定在产品本体的标识类物件统一归入此类,不再归入机械类。', NULL, 'active'),
(1, 'F03', '包装物料', '1.核心定义:产品整机交付使用的外包装箱体、外壳及缓冲防护、填充类配套物料。 2.包含说明:定制航空箱、外包装箱体、防护外箱、内衬框架、缓冲泡棉、填充棉、防护内衬、减震包装辅材。 3.现场举例:设备定制航空箱、整机外包装箱、成型泡棉内衬、定位缓冲泡棉、防震填充辅料。 4.边界区分:所有产品专属包装、防护、减震填充物料全部归入此类。', NULL, 'active'),
(1, 'F04', '随机工具配件', '1.核心定义:随产品整机交付给客户,供客户安装、调试、日常运维、拆装使用的小型工具、配套配件。 2.包含说明:设备专属交付工具、配套小配件,非设备本体结构件。 3.现场举例:随机内六角扳手、专用拆装工具、配套备用小配件。 4.边界区分:随货交付客户的工具归入此类;车间生产/自检自用工具、工装治具不纳入本BOM体系。', NULL, 'active'),
(1, 'F05', '其他', '1.核心定义:以上子类无法归类、随产品交付的其余附属配套物料。 2.包含说明:本大类无对应子类的产品配套杂项,统一兜底归入。', NULL, 'active');
-- ---------------------------------------------------------------------------
-- 5. 回填 parent_id
-- 子类编码为 3 位(如 A01),首字母即所属大类编码;大类编码为 1 位。
-- ---------------------------------------------------------------------------
UPDATE material_categories AS c
JOIN material_categories AS p
ON p.organization_id = c.organization_id
AND p.code = LEFT(c.code, 1)
SET c.parent_id = p.id
WHERE c.organization_id = 1
AND CHAR_LENGTH(c.code) = 3
AND c.parent_id IS NULL;
-- ---------------------------------------------------------------------------
-- 6. 编码规则:物料号 = 子类编码 + 4 位流水号
-- next_sequence 取自业务给出的「当前最大流水号 + 1」,
-- 故下一个物料号分别为 A010201、A020202、B010203,其余为 XNN0201。
-- ---------------------------------------------------------------------------
INSERT INTO code_rules (organization_id, category_id, prefix, sequence_length, next_sequence, status)
SELECT 1, c.id, c.code, 4,
CASE c.code WHEN 'A02' THEN 202 WHEN 'B01' THEN 203 ELSE 201 END,
'active'
FROM material_categories AS c
WHERE c.organization_id = 1
AND c.parent_id IS NOT NULL;
-- ---------------------------------------------------------------------------
-- 7. 校验
-- ---------------------------------------------------------------------------
SELECT '大类' AS 层级, COUNT(*) AS 数量 FROM material_categories WHERE organization_id = 1 AND parent_id IS NULL
UNION ALL
SELECT '子类', COUNT(*) FROM material_categories WHERE organization_id = 1 AND parent_id IS NOT NULL
UNION ALL
SELECT '编码规则', COUNT(*) FROM code_rules WHERE organization_id = 1
UNION ALL
SELECT '残留物料', COUNT(*) FROM materials;

Binary file not shown.