修改一致性问题

This commit is contained in:
文睿 张 2026-09-21 14:13:30 +08:00
parent 8a60e2cb00
commit ea63cd9931
37 changed files with 1428 additions and 154 deletions

View File

@ -10,9 +10,17 @@
# Build outputs (rebuilt inside container) # Build outputs (rebuilt inside container)
frontend/dist frontend/dist
server.exe
# Runtime data (mounted as a volume, must not be baked into the image)
uploads
# Editor history snapshots
.history
# Dependencies (reinstalled inside container) # Dependencies (reinstalled inside container)
node_modules # 用 **/ 前缀:不带斜杠的模式只匹配根级目录,拦不住 frontend/node_modules。
**/node_modules
vendor vendor
# Recovery files # Recovery files

117
DEPLOY.md
View File

@ -31,9 +31,46 @@ docker-compose ps
### 访问应用 ### 访问应用
- 前端地址: http://localhost - 前端地址: http://localhost
- 后端API: http://localhost:8082/api - 后端API: http://localhost:8082/api (绑定在 `127.0.0.1`,仅限本机)
- 健康检查: http://localhost:8082/api/health - 健康检查: http://localhost:8082/api/health
### 用手机 / 局域网其他设备访问
手机连上**与电脑同一个 WiFi**,浏览器打开 `http://<电脑的局域网IP>`,用下节账号登录即可,**不需要带端口号**(80 是 HTTP 的默认端口,浏览器会自动使用)。
先查看电脑的局域网 IP —— 取 `WLAN` 那一行的地址,忽略 `vEthernet (WSL)`、`VMware Network Adapter` 这些虚拟网卡:
```bash
ipconfig
```
例如地址是 `192.168.31.76`,手机就访问 `http://192.168.31.76`。
**为什么不需要改配置**:前端请求走相对路径 `/api`,手机访问时会自动打到 `http://<电脑IP>/api`,再由 Nginx 反代给后端容器。前后端都没有硬编码 `localhost`,所以换任何 IP 访问都能正常工作。
#### 端口绑定策略
| 服务 | 绑定地址 | 局域网可访问 | 原因 |
|------|----------|--------------|------|
| frontend | `0.0.0.0:80` | ✅ 是 | 手机、其他设备要打开网页 |
| backend | `127.0.0.1:8082` | ❌ 否 | 前端经 Nginx 走**容器内部网络**反代(`proxy_pass http://backend:8082`),不经过宿主机端口 |
| db | `127.0.0.1:3307` | ❌ 否 | 数据库不应暴露;本机仍可用客户端连 3307 调试 |
后端和数据库绑定在 `127.0.0.1` 是**有意的安全设置**,只允许本机访问。若你临时需要让别的机器直连后端 API 调试,把 `docker-compose.yml` 里对应那行改回 `"8082:8082"`,再执行 `docker compose up -d`(改完记得改回去)。
#### 手机打不开时怎么查
1. **确认同一个 WiFi**:手机和电脑必须是同一个 SSID。
2. **WiFi 的客户端隔离(AP 隔离)**:单位、公共场所的网络常开启此功能,导致设备之间无法互访。这属于网络策略,与本项目无关 —— 用手机开热点让电脑连上试一次即可确认。
3. **Windows 防火墙**:需放行 80 端口入站。Docker Desktop 安装时会按其网络类别自动添加放行规则,若仍不通,用管理员 PowerShell 临时加一条:
```powershell
New-NetFirewallRule -DisplayName "WMMS 80" -Direction Inbound -LocalPort 80 -Protocol TCP -Action Allow
# 测试完删除:Remove-NetFirewallRule -DisplayName "WMMS 80"
```
4. **IP 会变**:`192.168.x.x` 由 DHCP 分配,路由器重启后可能变化。想长期使用,建议在路由器上给这台电脑做静态 IP 绑定。
> ⚠️ 数据库使用默认密码 `wmms1234`,后端 API 也没有额外的访问控制,因此**不要把 8082 / 3307 暴露到公网**。
### 默认用户 ### 默认用户
| 用户名 | 密码 | 角色 | | 用户名 | 密码 | 角色 |
@ -65,6 +102,54 @@ docker-compose down -v
- **MySQL数据**: `mysql_data` Docker volume - **MySQL数据**: `mysql_data` Docker volume
- **上传文件**: `uploads_data` Docker volume - **上传文件**: `uploads_data` Docker volume
### ⚠️ 数据卷复用会导致表结构落后于代码
MySQL 官方镜像**只在数据目录为空时**才执行 `/docker-entrypoint-initdb.d/` 下的脚本。也就是说 `scripts/init.sql` **只在 `mysql_data` 卷第一次创建时执行一次**;只要卷还在,后续 `docker compose up -d` 一律直接复用旧数据,`init.sql` 完全不执行。
`docker compose down` **不带 `-v` 时卷会保留**,所以改过 schema 之后重新部署,新代码会跑在旧表结构上。典型症状是**容器全部 healthy、日志干净,但部分接口返回 500**:
```
Error 1054 (42S22): Unknown column 'inbound_no' in 'field list'
```
**部署前先确认卷的年龄**:
```bash
docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}'
```
把结果和最近一次改 schema 的提交时间对比:卷比代码新就没问题,卷比代码旧就要按下节处理。
**方式一:清空重建(推荐,最简单可靠)**
```bash
docker compose down -v && docker compose up -d --build
```
`-v` 会删除 `mysql_data` 和 `uploads_data` 两个卷,**库中数据全部丢失**。执行前务必确认没有需要保留的业务数据,必要时先备份:
```bash
docker compose exec db mysqldump -uroot -pwmms1234 --default-character-set=utf8mb4 wmms > backup.sql
```
**方式二:跑增量脚本(需要保留数据时)**
`scripts/` 下有三个手工迁移脚本,按需执行:
| 脚本 | 作用 | 是否破坏数据 |
|------|------|--------------|
| `add_order_display_fields.sql` | 出入库单补 `inbound_no` / `outbound_no` / `operator` / `remark` | 否,仅加列并回填单号 |
| `update_material_categories.sql` | 重建分类树并写入编码规则 | **是**,会清空物料、单据、库存、流水 |
| `update_warning_stock_zero.sql` | 清理阈值为 0 的历史预警规则 | **是**,会删除这些规则行 |
执行方式:
```bash
docker compose exec -T db mysql -uroot -pwmms1234 --default-character-set=utf8mb4 wmms < scripts/xxx.sql
```
执行前请先备份,并阅读脚本头部的说明。
## 环境变量 ## 环境变量
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
@ -109,6 +194,36 @@ docker-compose logs db
# 等待数据库启动完成(约10秒) # 等待数据库启动完成(约10秒)
``` ```
### 接口返回 500:Unknown column 'xxx'
**症状**:容器状态全部 healthy,`docker compose logs` 也看不到异常,但某些接口返回 500,例如:
```
Error 1054 (42S22): Unknown column 'description' in 'field list'
```
**原因**:`mysql_data` 卷是旧的,`init.sql` 没有执行,表结构落后于当前代码。详见上文《数据持久化 → ⚠️ 数据卷复用会导致表结构落后于代码》。
**确认**:
```bash
docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}'
```
如果卷的创建时间早于最近一次改 schema 的提交,就是这个原因。
**解决**:确认库里无业务数据后执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据则改跑 `scripts/` 下的增量脚本。
### 其他排查技巧
**哪些表结构会不一致?** 直接在容器里对比:
```bash
# 看当前库到底建了哪些表和列
docker compose exec db mysql -uroot -pwmms1234 -e "SHOW TABLES;" wmms
docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM inbound_orders;" wmms
```
### 查看容器内部 ### 查看容器内部
```bash ```bash

View File

@ -8,7 +8,8 @@ services:
MYSQL_ROOT_PASSWORD: wmms1234 MYSQL_ROOT_PASSWORD: wmms1234
MYSQL_DATABASE: wmms MYSQL_DATABASE: wmms
ports: ports:
- "3307:3306" # 只绑本机回环:数据库不对局域网暴露。容器之间仍走内部网络,互不影响。
- "127.0.0.1:3307:3306"
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
- ./scripts/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro - ./scripts/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
@ -30,7 +31,9 @@ services:
container_name: wmms-backend container_name: wmms-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8082:8082" # 只绑本机回环:后端 API 不对局域网暴露。前端经 Nginx 走容器内部网络
# 反代(proxy_pass http://backend:8082),不经过宿主机端口,故不受影响。
- "127.0.0.1:8082:8082"
environment: environment:
DB_DSN: "root:wmms1234@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true" DB_DSN: "root:wmms1234@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true"
GIN_MODE: release GIN_MODE: release

View File

@ -7,17 +7,8 @@ until mysql --default-character-set=utf8mb4 -h db -uroot -pwmms1234 --silent 2>/
done done
echo "MySQL is ready." echo "MySQL is ready."
# Only run init scripts if they haven't been run yet # 建表和初始数据由 db 容器在数据卷为空时执行 /docker-entrypoint-initdb.d/001-init.sql 完成。
if [ ! -f /app/.init_done ]; then # 该目录只挂载在 db 容器上,本容器内不存在,故此处不再重复执行初始化脚本。
echo "Running init scripts..."
for f in $(ls /docker-entrypoint-initdb.d/*.sql 2>/dev/null | sort); do
echo "Executing: $f"
mysql --default-character-set=utf8mb4 -h db -uroot -pwmms1234 wmms < "$f" && echo "OK: $(basename $f)" || { echo "FAILED: $(basename $f)"; cat /tmp/mysql_err.log 2>/dev/null; }
done
touch /app/.init_done
else
echo "Init already done, skipping."
fi
echo "Starting application..." echo "Starting application..."
exec "$@" exec "$@"

View File

@ -4,9 +4,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Force UTF-8 for all text # 统一以 UTF-8 返回文本。字符集由 charset 指令加到各资源自身的 MIME 类型上,
# 不能再 add_header Content-Type,否则会把 js/css 的 MIME 也覆盖成 text/html。
charset utf-8; charset utf-8;
add_header Content-Type "text/html; charset=utf-8";
# Gzip compression # Gzip compression
gzip on; gzip on;

View File

@ -22,7 +22,7 @@ const statusMap = {
inbound: { type: 'success', label: '入库' }, inbound: { type: 'success', label: '入库' },
outbound: { type: 'warning', label: '出库' }, outbound: { type: 'warning', label: '出库' },
stock_check: { type: 'info', label: '盘点' }, stock_check: { type: 'info', label: '盘点' },
INSERT: { type: 'success', label: '新增' }, CREATE: { type: 'success', label: '新增' },
UPDATE: { type: 'primary', label: '更新' }, UPDATE: { type: 'primary', label: '更新' },
DELETE: { type: 'danger', label: '删除' } DELETE: { type: 'danger', label: '删除' }
} }

View File

@ -13,7 +13,21 @@ export const useLogsStore = defineStore('logs', () => {
try { try {
const pageSize = page.value.pageSize const pageSize = page.value.pageSize
const offset = (pageNum - 1) * pageSize const offset = (pageNum - 1) * pageSize
const params = { limit: pageSize, offset, ...filters.value } const params = { limit: pageSize, offset }
// 后端只认 snake_case 的查询参数,这里是前后端字段名唯一的转换点。
// 空值不下发,避免拼出 operation_type= 这类无意义参数(后端按空串跳过条件,
// 但空参数会让日志和抓包难以阅读)。
const queryParams = {
operation_type: filters.value.operationType,
start_date: filters.value.startDate,
end_date: filters.value.endDate,
material_id: filters.value.materialId
}
for (const [key, value] of Object.entries(queryParams)) {
if (value !== '' && value !== null && value !== undefined) {
params[key] = value
}
}
const res = await logsApi.getAll(params) const res = await logsApi.getAll(params)
logs.value = res.data?.items || res.data || [] logs.value = res.data?.items || res.data || []
page.value.total = res.data?.total || logs.value.length page.value.total = res.data?.total || logs.value.length

View File

@ -0,0 +1,66 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { logsApi } from '../api/logs'
import { useLogsStore } from './logs'
vi.mock('../api/logs', () => ({
logsApi: { getAll: vi.fn() }
}))
describe('logs store 查询参数', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
logsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
})
// 后端按 snake_case 读查询参数(operation_type / material_id / start_date / end_date),
// 前端内部用 camelCase。这里少转一个字段,对应的筛选器就会静默失效——
// 不报错,只是点了查询没反应,所以要有测试盯着。
it('把筛选条件转成后端认的 snake_case 参数', async () => {
const store = useLogsStore()
store.setFilters({
operationType: 'CREATE',
startDate: '2026-09-01',
endDate: '2026-09-21',
materialId: 7
})
await store.fetchLogs()
expect(logsApi.getAll).toHaveBeenCalledWith({
limit: 20,
offset: 0,
operation_type: 'CREATE',
start_date: '2026-09-01',
end_date: '2026-09-21',
material_id: 7
})
})
it('筛选条件为空时不下发对应参数', async () => {
const store = useLogsStore()
await store.fetchLogs()
expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 })
})
it('翻页时按页大小算出 offset', async () => {
const store = useLogsStore()
await store.fetchLogs(3)
expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 40 })
})
it('重置筛选条件后不再下发任何筛选项', async () => {
const store = useLogsStore()
store.setFilters({ operationType: 'DELETE', materialId: 7 })
store.resetFilters()
await store.fetchLogs()
expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 })
})
})

View File

@ -0,0 +1,28 @@
// 需要按「无」处理的时间前缀:
// - 0001-01-01 是 Go time.Time 的零值,JSON 序列化后为 0001-01-01T00:00:00Z。
// encoding/json 的 omitempty 对 time.Time 这类结构体不生效,字段会照常输出,
// 不拦的话界面上会渲染成 0001/1/1。
// - 0000-00-00 是 MySQL 的零日期。
const ZERO_DATE_PREFIXES = ['0001-01-01', '0000-00-00']
// 格式化时间用于界面展示:空值、零值时间和无法解析的值统一返回 '-'。
export function formatDate(value, options) {
// 只把 null / undefined / 空串当空值:0 是合法时间戳(1970-01-01),用 !value 会误伤
if (value === null || value === undefined || value === '') return '-'
if (typeof value === 'string' && ZERO_DATE_PREFIXES.some((prefix) => value.startsWith(prefix))) return '-'
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', options)
}
// 把日期对象转成查询参数用的 YYYY-MM-DD。
// 不能用 toISOString() 裁前 10 位:它按 UTC 取日期,东八区选的 9/21 00:00
// 会被算成 9/20,查询范围整整偏一天。日期结束时刻由后端的 normalizeEndDate
// 统一补成当天 23:59:59,前端只传日期即可。
export function toDateParam(value) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (num) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}

View File

@ -0,0 +1,73 @@
import { describe, expect, test } from 'vitest'
import { formatDate, toDateParam } from './datetime'
describe('时间展示格式化', () => {
test('空值显示为 -', () => {
expect(formatDate(null)).toBe('-')
expect(formatDate(undefined)).toBe('-')
expect(formatDate('')).toBe('-')
})
test('Go 的零值时间显示为 -', () => {
// 后端 time.Time 零值序列化后的形态,不能落成 0001/1/1
expect(formatDate('0001-01-01T00:00:00Z')).toBe('-')
expect(formatDate('0001-01-01 00:00:00')).toBe('-')
})
test('MySQL 的零日期显示为 -', () => {
expect(formatDate('0000-00-00 00:00:00')).toBe('-')
expect(formatDate('0000-00-00')).toBe('-')
})
test('无法解析的值显示为 -', () => {
expect(formatDate('not-a-date')).toBe('-')
})
test('正常值按本地时区展示', () => {
const value = '2026-09-21T10:30:00Z'
expect(formatDate(value)).toBe(new Date(value).toLocaleString('zh-CN'))
})
test('传入自定义格式选项时按选项展示', () => {
const value = '2026-09-21T10:30:00Z'
const options = { year: 'numeric', month: '2-digit', day: '2-digit' }
expect(formatDate(value, options)).toBe(new Date(value).toLocaleString('zh-CN', options))
})
test('接受 Date 对象', () => {
const value = new Date('2026-09-21T10:30:00Z')
expect(formatDate(value)).toBe(value.toLocaleString('zh-CN'))
})
test('时间戳为 0 时仍按正常时间展示,不当作空值', () => {
// 0 是合法时间戳(1970-01-01),只有 falsy 才兜底会误伤它
const value = new Date(0)
expect(formatDate(0)).toBe(value.toLocaleString('zh-CN'))
})
})
describe('查询参数日期格式化', () => {
test('空值返回空串', () => {
expect(toDateParam(null)).toBe('')
expect(toDateParam(undefined)).toBe('')
expect(toDateParam('')).toBe('')
})
test('无法解析的值返回空串', () => {
expect(toDateParam('not-a-date')).toBe('')
})
test('按本地时区取日期,不因 UTC 换算偏一天', () => {
// 本地时间当天 00:00。东八区这一刻的 UTC 还是前一天 16:00,
// 若改用 toISOString() 裁前 10 位会得到 9/20,查询范围整整偏一天。
expect(toDateParam(new Date(2026, 8, 21))).toBe('2026-09-21')
})
test('月日补零成两位', () => {
expect(toDateParam(new Date(2026, 0, 5))).toBe('2026-01-05')
})
test('接受日期字符串', () => {
expect(toDateParam(new Date(2026, 8, 21).toISOString())).toBe('2026-09-21')
})
})

View File

@ -3,8 +3,8 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="发起申请" name="apply"> <el-tab-pane label="发起申请" name="apply">
<el-form :model="applyForm" label-width="100px"> <el-form :model="applyForm" label-width="100px">
<el-form-item label="申请用途"> <el-form-item label="申请用途" required>
<el-input v-model="applyForm.purpose" type="textarea" rows="3" placeholder="请填写申请用途(必填)" /> <el-input v-model="applyForm.purpose" type="textarea" rows="3" placeholder="请填写申请用途" />
</el-form-item> </el-form-item>
<el-form-item label="申请物料"> <el-form-item label="申请物料">
<div v-for="(item, index) in applyForm.items" :key="index" class="item-row"> <div v-for="(item, index) in applyForm.items" :key="index" class="item-row">
@ -64,7 +64,6 @@
:total="applicationStore.myPage.total" :total="applicationStore.myPage.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="application_no" label="申请单号" /> <el-table-column prop="application_no" label="申请单号" />
<el-table-column prop="items" label="物料明细"> <el-table-column prop="items" label="物料明细">
<template #default="{ row }"> <template #default="{ row }">
@ -113,6 +112,7 @@ import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { searchMaterialOptions } from '../utils/materialSearch' import { searchMaterialOptions } from '../utils/materialSearch'
import { formatDate } from '../utils/datetime'
const applicationStore = useApplicationStore() const applicationStore = useApplicationStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -225,11 +225,6 @@ async function handleCancel(id) {
// error handled by store // error handled by store
} }
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,91 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Approval from './Approval.vue'
const approvalStore = vi.hoisted(() => ({
applications: [],
loading: false,
page: { currentPage: 1, pageSize: 20, total: 0 },
fetchPendingApplications: vi.fn(),
approveApplication: vi.fn(),
rejectApplication: vi.fn()
}))
vi.mock('../stores/approval', () => ({ useApprovalStore: () => approvalStore }))
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
function mountApproval() {
return mount(Approval, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
}
describe('审批自动出库默认值', () => {
beforeEach(() => {
approvalStore.fetchPendingApplications.mockReset()
approvalStore.approveApplication.mockReset().mockResolvedValue({ data: {} })
approvalStore.rejectApplication.mockReset().mockResolvedValue({ data: {} })
})
// el-table 的行内容在 jsdom 下渲染不出来(列宽依赖真实布局计算),
// 于是跳过表格这一层,直接触发组件的打开逻辑。
async function openApproveDialog(wrapper) {
wrapper.vm.$.setupState.showApproveModal({ id: 9, status: 'pending' })
await flushPromises()
}
test('打开审批弹窗时自动出库默认已勾选', async () => {
const wrapper = mountApproval()
await flushPromises()
await openApproveDialog(wrapper)
expect(wrapper.find('.el-switch').classes()).toContain('is-checked')
wrapper.unmount()
})
test('默认提交时 auto_outbound 为 true', async () => {
const wrapper = mountApproval()
await flushPromises()
await openApproveDialog(wrapper)
await findButton(wrapper, '确认通过').trigger('click')
await flushPromises()
expect(approvalStore.approveApplication).toHaveBeenCalledWith(9, { auto_outbound: true })
wrapper.unmount()
})
test('手动关掉开关后可以只审批不出库', async () => {
const wrapper = mountApproval()
await flushPromises()
await openApproveDialog(wrapper)
await wrapper.find('.el-switch').trigger('click')
await flushPromises()
await findButton(wrapper, '确认通过').trigger('click')
await flushPromises()
expect(approvalStore.approveApplication).toHaveBeenCalledWith(9, { auto_outbound: false })
wrapper.unmount()
})
test('关掉后重新打开会回到默认勾选', async () => {
const wrapper = mountApproval()
await flushPromises()
await openApproveDialog(wrapper)
await wrapper.find('.el-switch').trigger('click')
await flushPromises()
expect(wrapper.find('.el-switch').classes()).not.toContain('is-checked')
await openApproveDialog(wrapper)
expect(wrapper.find('.el-switch').classes()).toContain('is-checked')
wrapper.unmount()
})
})

View File

@ -18,7 +18,6 @@
:total="approvalStore.page.total" :total="approvalStore.page.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="application_no" label="申请单号" /> <el-table-column prop="application_no" label="申请单号" />
<el-table-column label="申请人" width="100"> <el-table-column label="申请人" width="100">
<template #default="{ row }"> <template #default="{ row }">
@ -71,7 +70,7 @@
<el-dialog v-model="rejectDialogVisible" title="拒绝申请" width="400px"> <el-dialog v-model="rejectDialogVisible" title="拒绝申请" width="400px">
<el-form :model="rejectForm" label-width="100px"> <el-form :model="rejectForm" label-width="100px">
<el-form-item label="拒绝原因"> <el-form-item label="拒绝原因" required>
<el-input v-model="rejectForm.reason" type="textarea" rows="3" /> <el-input v-model="rejectForm.reason" type="textarea" rows="3" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -89,6 +88,7 @@ import { useApprovalStore } from '../stores/approval'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { formatDate } from '../utils/datetime'
const approvalStore = useApprovalStore() const approvalStore = useApprovalStore()
@ -97,7 +97,11 @@ const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false) const rejectDialogVisible = ref(false)
const currentApplication = ref(null) const currentApplication = ref(null)
const approveForm = reactive({ autoOutbound: false }) // 审批默认勾选自动出库:通过即扣减库存并生成出库单,省去再去出库页手工建单。
// 初始值和每次打开弹窗时的重置都引用它,避免默认值改动时只改一处。
const AUTO_OUTBOUND_DEFAULT = true
const approveForm = reactive({ autoOutbound: AUTO_OUTBOUND_DEFAULT })
const rejectForm = reactive({ reason: '' }) const rejectForm = reactive({ reason: '' })
onMounted(() => { onMounted(() => {
@ -114,7 +118,7 @@ function handlePageChange(page) {
function showApproveModal(row) { function showApproveModal(row) {
currentApplication.value = row currentApplication.value = row
approveForm.autoOutbound = false approveForm.autoOutbound = AUTO_OUTBOUND_DEFAULT
approveDialogVisible.value = true approveDialogVisible.value = true
} }
@ -143,11 +147,6 @@ async function handleReject() {
ElMessage.success('已拒绝') ElMessage.success('已拒绝')
rejectDialogVisible.value = false rejectDialogVisible.value = false
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -13,7 +13,6 @@
:total="inboundStore.page.total" :total="inboundStore.page.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="inbound_no" label="入库单号" /> <el-table-column prop="inbound_no" label="入库单号" />
<el-table-column prop="status" label="状态"> <el-table-column prop="status" label="状态">
<template #default="{ row }"> <template #default="{ row }">
@ -24,16 +23,11 @@
<el-table-column prop="handler" label="经手人" /> <el-table-column prop="handler" label="经手人" />
<el-table-column prop="supplier" label="供应商" /> <el-table-column prop="supplier" label="供应商" />
<el-table-column prop="remark" label="备注" /> <el-table-column prop="remark" label="备注" />
<el-table-column prop="created_at" label="创建时间" width="160"> <el-table-column prop="created_at" label="创建时间" width="160" class-name="pr-3">
<template #default="{ row }"> <template #default="{ row }">
{{ formatDate(row.created_at) }} {{ formatDate(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160" class-name="pr-3">
<template #default="{ row }">
{{ formatDate(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right" class-name="border-left"> <el-table-column label="操作" width="240" fixed="right" class-name="border-left">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button> <el-button size="small" @click="showDetail(row)">详情</el-button>
@ -146,6 +140,7 @@ import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { errorText } from '../utils/errorText' import { errorText } from '../utils/errorText'
import { formatDate } from '../utils/datetime'
const inboundStore = useInboundStore() const inboundStore = useInboundStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -377,11 +372,6 @@ async function confirmDelete() {
await inboundStore.deleteInbound(deleteId.value) await inboundStore.deleteInbound(deleteId.value)
ElMessage.success('删除成功') ElMessage.success('删除成功')
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -7,9 +7,9 @@
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filters.operationType" placeholder="操作类型" style="width: 150px"> <el-select v-model="filters.operationType" placeholder="操作类型" style="width: 150px">
<el-option label="全部" value="" /> <el-option label="全部" value="" />
<el-option label="入库" value="inbound" /> <el-option label="新增" value="CREATE" />
<el-option label="出库" value="outbound" /> <el-option label="更新" value="UPDATE" />
<el-option label="盘点" value="stock_check" /> <el-option label="删除" value="DELETE" />
</el-select> </el-select>
<el-date-picker <el-date-picker
v-model="filters.dateRange" v-model="filters.dateRange"
@ -31,7 +31,7 @@
<el-option <el-option
v-for="m in getFilteredMaterialsForLogs()" v-for="m in getFilteredMaterialsForLogs()"
:key="m.id" :key="m.id"
:label="`${m.material_code} - ${m.name}`" :label="logsMaterialLabel(m)"
:value="m.id" :value="m.id"
/> />
</el-select> </el-select>
@ -47,7 +47,6 @@
:total="logsStore.page.total" :total="logsStore.page.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="operation_type" label="操作类型"> <el-table-column prop="operation_type" label="操作类型">
<template #default="{ row }"> <template #default="{ row }">
<StatusBadge :status="row.operation_type" /> <StatusBadge :status="row.operation_type" />
@ -55,7 +54,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="material_id" label="物料ID" width="80" /> <el-table-column prop="material_id" label="物料ID" width="80" />
<el-table-column prop="operator" label="操作人" /> <el-table-column prop="operator" label="操作人" />
<el-table-column prop="detail" label="详情" show-overflow-tooltip> <el-table-column prop="description" label="详情" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="showDetail(row)">查看详情</el-button> <el-button type="primary" link @click="showDetail(row)">查看详情</el-button>
</template> </template>
@ -70,10 +69,10 @@
<el-dialog v-model="detailVisible" title="操作详情" width="700px"> <el-dialog v-model="detailVisible" title="操作详情" width="700px">
<el-descriptions :column="2" border v-if="currentRow"> <el-descriptions :column="2" border v-if="currentRow">
<el-descriptions-item label="物料ID">{{ currentRow.material_id }}</el-descriptions-item> <el-descriptions-item label="物料ID">{{ currentRow.material_id }}</el-descriptions-item>
<el-descriptions-item label="操作类型" :span="2">{{ currentRow.operation_type }}</el-descriptions-item> <el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item> <el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item>
<el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item> <el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item>
<el-descriptions-item label="详情" :span="2">{{ currentRow.detail }}</el-descriptions-item> <el-descriptions-item label="详情" :span="2">{{ currentRow.description }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare"> <div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare">
<h4>变更对比</h4> <h4>变更对比</h4>
@ -96,6 +95,8 @@ import { useLogsStore } from '../stores/logs'
import { useMaterialsStore } from '../stores/materials' import { useMaterialsStore } from '../stores/materials'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { formatDate, toDateParam } from '../utils/datetime'
import { searchMaterialOptions } from '../utils/materialSearch'
const logsStore = useLogsStore() const logsStore = useLogsStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -109,12 +110,13 @@ function logsMaterialFilter(val) {
} }
function getFilteredMaterialsForLogs() { function getFilteredMaterialsForLogs() {
const keyword = (logsMaterialSearchText.value || '').toLowerCase() return searchMaterialOptions(materialsStore.historicalMaterialOptions, logsMaterialSearchText.value)
if (!keyword) return materialsStore.materials }
return materialsStore.materials.filter(m =>
m.material_code.toLowerCase().includes(keyword) || // 检索框里会出现已作废的物料,标注出来免得和有效物料混淆。
m.name.toLowerCase().includes(keyword) function logsMaterialLabel(material) {
) const label = `${material.material_code} - ${material.name}`
return material.status === 'obsolete' ? `${label}(已作废)` : label
} }
function onMaterialSelect(id) { function onMaterialSelect(id) {
@ -144,16 +146,18 @@ const filters = reactive({
materialId: '' materialId: ''
}) })
onMounted(async () => { onMounted(() => {
logsStore.fetchLogs() logsStore.fetchLogs()
await materialsStore.fetchMaterials(1, '') // 日志是历史记录,检索框要能选中已作废物料,所以用含作废的那份选项,
// 而不是只装了当前页的分页列表 materials。
materialsStore.fetchHistoricalMaterialOptions()
}) })
function handleSearch() { function handleSearch() {
logsStore.setFilters({ logsStore.setFilters({
operationType: filters.operationType, operationType: filters.operationType,
startDate: filters.dateRange?.[0]?.toISOString(), startDate: toDateParam(filters.dateRange?.[0]),
endDate: filters.dateRange?.[1]?.toISOString(), endDate: toDateParam(filters.dateRange?.[1]),
materialId: filters.materialId materialId: filters.materialId
}) })
logsStore.fetchLogs() logsStore.fetchLogs()
@ -171,11 +175,6 @@ function handleReset() {
function handlePageChange(page) { function handlePageChange(page) {
logsStore.fetchLogs(page) logsStore.fetchLogs(page)
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -124,8 +124,8 @@
<el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item> <el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item> <el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item> <el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
<el-descriptions-item label="物料图片" :span="2"> <el-descriptions-item label="物料图片" :span="2">
<el-image <el-image
v-if="detailRow.material_image" v-if="detailRow.material_image"
@ -164,6 +164,16 @@ import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories' import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
import { searchMaterialOptions } from '../utils/materialSearch' import { searchMaterialOptions } from '../utils/materialSearch'
import { errorText } from '../utils/errorText' import { errorText } from '../utils/errorText'
import { formatDate } from '../utils/datetime'
// 物料详情的展示沿用原先不带秒的格式
const MATERIAL_DATE_OPTIONS = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -402,16 +412,6 @@ async function handleRestore(id) {
} }
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script> </script>
<style scoped> <style scoped>

View File

@ -13,7 +13,6 @@
:total="outboundStore.page.total" :total="outboundStore.page.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="outbound_no" label="出库单号" /> <el-table-column prop="outbound_no" label="出库单号" />
<el-table-column prop="status" label="状态"> <el-table-column prop="status" label="状态">
<template #default="{ row }"> <template #default="{ row }">
@ -90,6 +89,7 @@
<p><strong>操作人:</strong>{{ currentRow.operator }}</p> <p><strong>操作人:</strong>{{ currentRow.operator }}</p>
<p><strong>备注:</strong>{{ currentRow.remark || '-' }}</p> <p><strong>备注:</strong>{{ currentRow.remark || '-' }}</p>
<p><strong>创建时间:</strong>{{ formatDate(currentRow.created_at) }}</p> <p><strong>创建时间:</strong>{{ formatDate(currentRow.created_at) }}</p>
<p><strong>更新时间:</strong>{{ formatDate(currentRow.updated_at) }}</p>
<div v-if="currentRow.items && currentRow.items.length > 0" class="mt-3"> <div v-if="currentRow.items && currentRow.items.length > 0" class="mt-3">
<h5>物料明细</h5> <h5>物料明细</h5>
<el-table :data="currentRow.items" border size="small"> <el-table :data="currentRow.items" border size="small">
@ -113,6 +113,7 @@ import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { formatDate } from '../utils/datetime'
const outboundStore = useOutboundStore() const outboundStore = useOutboundStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -293,11 +294,6 @@ async function showDetail(row) {
} }
detailVisible.value = true detailVisible.value = true
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,125 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Stock from './Stock.vue'
const stockStore = vi.hoisted(() => ({
stockList: [],
warningList: [],
logs: [],
loading: false,
stockPage: { currentPage: 1, pageSize: 20, total: 0 },
warningPage: { currentPage: 1, pageSize: 20, total: 0 },
logsPage: { currentPage: 1, pageSize: 20, total: 0 },
fetchStock: vi.fn(),
fetchStockLogs: vi.fn(),
fetchStockWarning: vi.fn(),
updateWarningStock: vi.fn()
}))
const materialsStore = vi.hoisted(() => ({
materialOptions: [],
historicalMaterialOptions: [],
fetchMaterialOptions: vi.fn(),
fetchHistoricalMaterialOptions: vi.fn()
}))
vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore }))
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
vi.mock('../api/stock', () => ({
stockApi: { getInventoryBalanceDetails: vi.fn().mockResolvedValue({ data: [] }) }
}))
vi.mock('../api/masterData', () => ({
materialCategoriesApi: { list: vi.fn().mockResolvedValue({ data: [] }) }
}))
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
const TabPaneStub = { template: '<div><slot /></div>' }
function mountStock() {
return mount(Stock, {
global: {
plugins: [ElementPlus],
// WarehouseSettings 及其依赖与本用例无关,直接替身掉;
// ElTabPane 在 jsdom 下会让表格列渲染不出来,用直通替身绕开。
stubs: { teleport: true, WarehouseSettings: true, ElTabPane: TabPaneStub }
}
})
}
describe('库存预警阈值校验', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
stockStore.stockList = [{
material_id: 3,
material_code: 'A010203',
name: '测试物料',
total_on_hand: '10.000000',
warning_stock: '5.000000',
warning_enabled: true,
warning_configured: true,
stock_status: 'normal'
}]
stockStore.fetchStock.mockReset()
stockStore.fetchStockLogs.mockReset()
stockStore.fetchStockWarning.mockReset()
stockStore.updateWarningStock.mockReset().mockResolvedValue({ data: {} })
materialsStore.fetchMaterialOptions.mockReset()
materialsStore.fetchHistoricalMaterialOptions.mockReset()
})
afterEach(() => {
// 只恢复 ElMessage 的 spy:restoreAllMocks 会把 vi.mock 工厂里的实现一并清掉,
// 导致下一个用例中 api mock 返回 undefined。
warnSpy.mockRestore()
})
test('阈值被清空时拦下,不提交 null', async () => {
const wrapper = mountStock()
await flushPromises()
// el-table 的行内容在 jsdom 下渲染不出来(列宽依赖真实布局计算),
// 于是跳过表格这一层,直接触发组件的打开逻辑。
wrapper.vm.$.setupState.openWarningDialog(stockStore.stockList[0])
await flushPromises()
// el-input-number 清空后会向 v-model 写 null
const threshold = wrapper.find('.el-input-number input')
await threshold.setValue('')
await threshold.trigger('blur')
await flushPromises()
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
expect(stockStore.updateWarningStock).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写预警阈值')
wrapper.unmount()
})
test('阈值为 0 是合法配置,可以提交', async () => {
const wrapper = mountStock()
await flushPromises()
wrapper.vm.$.setupState.openWarningDialog(stockStore.stockList[0])
await flushPromises()
const threshold = wrapper.find('.el-input-number input')
await threshold.setValue('0')
await threshold.trigger('blur')
await flushPromises()
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
expect(warnSpy).not.toHaveBeenCalled()
expect(stockStore.updateWarningStock).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})

View File

@ -81,7 +81,6 @@
:total="stockStore.warningPage.total" :total="stockStore.warningPage.total"
@page-change="handleWarningPageChange" @page-change="handleWarningPageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="name" label="物料名称" /> <el-table-column prop="name" label="物料名称" />
<el-table-column prop="material_code" label="物料编码" /> <el-table-column prop="material_code" label="物料编码" />
<el-table-column prop="total_on_hand" label="当前库存"> <el-table-column prop="total_on_hand" label="当前库存">
@ -126,7 +125,6 @@
:total="stockStore.logsPage.total" :total="stockStore.logsPage.total"
@page-change="handleLogsPageChange" @page-change="handleLogsPageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="material_id" label="物料ID" /> <el-table-column prop="material_id" label="物料ID" />
<el-table-column prop="change_type" label="变动类型"> <el-table-column prop="change_type" label="变动类型">
<template #default="{ row }"> <template #default="{ row }">
@ -163,7 +161,7 @@
<el-switch v-model="warningEnabled" /> <el-switch v-model="warningEnabled" />
<span class="form-hint">关闭后该物料不再出现在库存预警列表,阈值会保留</span> <span class="form-hint">关闭后该物料不再出现在库存预警列表,阈值会保留</span>
</el-form-item> </el-form-item>
<el-form-item label="预警阈值"> <el-form-item label="预警阈值" required>
<el-input-number v-model="warningValue" :min="0" :step="1" :disabled="!warningEnabled" /> <el-input-number v-model="warningValue" :min="0" :step="1" :disabled="!warningEnabled" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -218,6 +216,7 @@ import { errorText } from '../utils/errorText'
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'
import { formatDate } from '../utils/datetime'
const stockStore = useStockStore() const stockStore = useStockStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -309,6 +308,12 @@ async function openDetailDialog(row) {
async function saveWarning() { async function saveWarning() {
if (!warningMaterial.value) return if (!warningMaterial.value) return
// 阈值被清空时 el-input-number 会给出 null,后端对 null 静默取 0,
// 会把已配好的阈值无声改掉,所以这里先拦下来。
if (warningValue.value === null || warningValue.value === undefined) {
ElMessage.warning('请填写预警阈值')
return
}
warningSaving.value = true warningSaving.value = true
try { try {
await stockStore.updateWarningStock(warningMaterial.value.material_id, warningValue.value, warningEnabled.value) await stockStore.updateWarningStock(warningMaterial.value.material_id, warningValue.value, warningEnabled.value)
@ -347,11 +352,6 @@ function handleLogsPageChange(page) {
stockStore.fetchStockLogs(logMaterialId.value || null, page) stockStore.fetchStockLogs(logMaterialId.value || null, page)
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
function getReferenceTypeName(type) { function getReferenceTypeName(type) {
switch (type) { switch (type) {
case 'inbound': return '入库单' case 'inbound': return '入库单'

View File

@ -23,7 +23,6 @@
:total="usersStore.page.total" :total="usersStore.page.total"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<el-table-column type="index" label="ID" width="60" />
<el-table-column prop="username" label="用户名" /> <el-table-column prop="username" label="用户名" />
<el-table-column label="角色" min-width="220"> <el-table-column label="角色" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
@ -54,13 +53,13 @@
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px">
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="80px">
<el-form-item label="用户名"> <el-form-item label="用户名" required>
<el-input v-model="form.username" :readonly="!!editingId" /> <el-input v-model="form.username" :readonly="!!editingId" />
</el-form-item> </el-form-item>
<el-form-item label="密码" v-if="!editingId"> <el-form-item label="密码" v-if="!editingId" required>
<el-input v-model="form.password" type="password" /> <el-input v-model="form.password" type="password" />
</el-form-item> </el-form-item>
<el-form-item label="角色"> <el-form-item label="角色" required>
<el-select v-model="form.role_ids" multiple style="width: 100%" placeholder="请选择至少一个角色"> <el-select v-model="form.role_ids" multiple style="width: 100%" placeholder="请选择至少一个角色">
<el-option <el-option
v-for="role in roles" v-for="role in roles"
@ -93,6 +92,7 @@ import { organizationsApi } from '../api/organizations'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { formatDate } from '../utils/datetime'
const usersStore = useUsersStore() const usersStore = useUsersStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -203,11 +203,6 @@ async function confirmDelete() {
await usersStore.deleteUser(deleteId.value) await usersStore.deleteUser(deleteId.value)
ElMessage.success('删除成功') ElMessage.success('删除成功')
} }
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,111 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import WarehouseSettings from './WarehouseSettings.vue'
import { warehousesApi } from '../../api/warehouses'
vi.mock('../../api/warehouses', () => ({
warehousesApi: {
list: vi.fn(),
create: vi.fn(),
createLocation: vi.fn()
}
}))
// 表单项没有 placeholder,按 label 文本定位输入框。
function inputByLabel(wrapper, label) {
const item = wrapper.findAll('.el-form-item').find(
node => node.find('.el-form-item__label').text() === label
)
return item.find('input')
}
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
async function clickSave(wrapper) {
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
}
describe('仓库与库位表单必填校验', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
warehousesApi.list.mockReset().mockResolvedValue({ data: [] })
warehousesApi.create.mockReset().mockResolvedValue({ data: {} })
warehousesApi.createLocation.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('新增仓库时编码留空被拦下', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
await flushPromises()
await clickSave(wrapper)
expect(warehousesApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写编码')
wrapper.unmount()
})
test('只填了编码时提示名称', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '编码').setValue('WH-002')
await clickSave(wrapper)
expect(warehousesApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写名称')
wrapper.unmount()
})
test('新增库位走同一套校验', async () => {
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '默认仓库', status: 'active', locations: [] }]
})
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
await flushPromises()
await findButton(wrapper, '新增库位').trigger('click')
await flushPromises()
await clickSave(wrapper)
expect(warehousesApi.createLocation).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写编码')
wrapper.unmount()
})
test('编码和名称都填好后正常提交', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '编码').setValue('WH-003')
await inputByLabel(wrapper, '名称').setValue('二号仓')
await clickSave(wrapper)
expect(warnSpy).not.toHaveBeenCalled()
expect(warehousesApi.create).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})

View File

@ -14,8 +14,8 @@
<el-dialog v-model="formVisible" :title="formType === 'warehouse' ? '新增仓库' : `新增库位 - ${selectedWarehouse?.name || ''}`" width="420px" append-to-body> <el-dialog v-model="formVisible" :title="formType === 'warehouse' ? '新增仓库' : `新增库位 - ${selectedWarehouse?.name || ''}`" width="420px" append-to-body>
<el-form :model="form" label-width="90px"> <el-form :model="form" label-width="90px">
<el-form-item label="编码"><el-input v-model="form.code" /></el-form-item> <el-form-item label="编码" required><el-input v-model="form.code" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item> <el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
</el-form> </el-form>
<template #footer><el-button @click="formVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template> <template #footer><el-button @click="formVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog> </el-dialog>
@ -37,7 +37,12 @@ async function load() { loading.value = true; try { const response = await wareh
function resetForm() { Object.assign(form, { code: '', name: '', status: 'active' }) } function resetForm() { Object.assign(form, { code: '', name: '', status: 'active' }) }
function openWarehouse() { formType.value = 'warehouse'; selectedWarehouse.value = null; resetForm(); formVisible.value = true } function openWarehouse() { formType.value = 'warehouse'; selectedWarehouse.value = null; resetForm(); formVisible.value = true }
function openLocation(warehouse) { formType.value = 'location'; selectedWarehouse.value = warehouse; resetForm(); formVisible.value = true } function openLocation(warehouse) { formType.value = 'location'; selectedWarehouse.value = warehouse; resetForm(); formVisible.value = true }
async function save() { saving.value = true; try { if (formType.value === 'warehouse') await warehousesApi.create(form); else await warehousesApi.createLocation(selectedWarehouse.value.id, form); ElMessage.success('保存成功'); formVisible.value = false; await load() } finally { saving.value = false } } async function save() {
if (!form.code.trim()) { ElMessage.warning('请填写编码'); return }
if (!form.name.trim()) { ElMessage.warning('请填写名称'); return }
saving.value = true
try { if (formType.value === 'warehouse') await warehousesApi.create(form); else await warehousesApi.createLocation(selectedWarehouse.value.id, form); ElMessage.success('保存成功'); formVisible.value = false; await load() } finally { saving.value = false }
}
onMounted(load) onMounted(load)
</script> </script>

View File

@ -1,6 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus' import ElementPlus, { ElMessage } from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import MasterDataSettings from './MasterDataSettings.vue' import MasterDataSettings from './MasterDataSettings.vue'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData'
@ -13,9 +13,25 @@ vi.mock('../../api/masterData', () => ({
const STANDARD_PART_DESCRIPTION = const STANDARD_PART_DESCRIPTION =
'1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。' '1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。'
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。
const SelectStub = {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = {
props: ['label', 'value'],
template: '<span class="option">{{ label }}</span>'
}
function mountSettings() { function mountSettings() {
return mount(MasterDataSettings, { return mount(MasterDataSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } } global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub }
}
}) })
} }
@ -70,3 +86,100 @@ describe('物料分类描述详情', () => {
wrapper.unmount() wrapper.unmount()
}) })
}) })
describe('基础配置表单必填校验', () => {
let warnSpy
// 表单项没有 placeholder,按 label 文本定位输入框。
function inputByLabel(wrapper, label) {
return wrapper.findAll('.el-form-item')
.find(node => node.find('.el-form-item__label').text() === label)
.find('input')
}
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
async function openCreateDialog(wrapper) {
await findButton(wrapper, '新增').trigger('click')
await flushPromises()
}
async function clickSave(wrapper) {
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
}
async function switchTab(wrapper, label) {
await wrapper.findAll('.el-tabs__item').find(node => node.text() === label).trigger('click')
await flushPromises()
}
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
materialCategoriesApi.list.mockResolvedValue({ data: [] })
materialCategoriesApi.create.mockReset().mockResolvedValue({ data: {} })
materialCategoriesApi.update.mockReset().mockResolvedValue({ data: {} })
unitsApi.list.mockResolvedValue({ data: [] })
unitsApi.create.mockReset().mockResolvedValue({ data: {} })
codeRulesApi.list.mockResolvedValue({ data: [] })
codeRulesApi.create.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('物料分类 tab:编码留空被拦下', async () => {
const wrapper = mountSettings()
await flushPromises()
await openCreateDialog(wrapper)
await clickSave(wrapper)
expect(materialCategoriesApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写编码')
wrapper.unmount()
})
test('物料分类 tab:只填了编码时提示名称', async () => {
const wrapper = mountSettings()
await flushPromises()
await openCreateDialog(wrapper)
await inputByLabel(wrapper, '编码').setValue('A03')
await clickSave(wrapper)
expect(materialCategoriesApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写名称')
wrapper.unmount()
})
test('编码规则 tab:分类未选时提示物料分类', async () => {
const wrapper = mountSettings()
await flushPromises()
await switchTab(wrapper, '编码规则')
await openCreateDialog(wrapper)
await clickSave(wrapper)
expect(codeRulesApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请选择物料分类')
wrapper.unmount()
})
test('编码规则 tab 不校验编码与名称', async () => {
const wrapper = mountSettings()
await flushPromises()
await switchTab(wrapper, '编码规则')
await openCreateDialog(wrapper)
// 分类和前缀都为空,应当只提示分类,不应冒出"请填写编码"
await clickSave(wrapper)
expect(warnSpy).not.toHaveBeenCalledWith('请填写编码')
expect(warnSpy).not.toHaveBeenCalledWith('请填写名称')
wrapper.unmount()
})
})

View File

@ -45,10 +45,10 @@
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${current.label}`" width="480px"> <el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${current.label}`" width="480px">
<el-form :model="form" label-width="100px"> <el-form :model="form" label-width="100px">
<el-form-item v-if="activeTab !== 'rules'" label="编码"> <el-form-item v-if="activeTab !== 'rules'" label="编码" required>
<el-input v-model="form.code" :disabled="Boolean(editingId)" /> <el-input v-model="form.code" :disabled="Boolean(editingId)" />
</el-form-item> </el-form-item>
<el-form-item v-if="activeTab !== 'rules'" label="名称"> <el-form-item v-if="activeTab !== 'rules'" label="名称" required>
<el-input v-model="form.name" /> <el-input v-model="form.name" />
</el-form-item> </el-form-item>
<el-form-item v-if="activeTab === 'categories'" label="上级大类"> <el-form-item v-if="activeTab === 'categories'" label="上级大类">
@ -68,12 +68,12 @@
<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>
<template v-if="activeTab === 'rules'"> <template v-if="activeTab === 'rules'">
<el-form-item label="物料分类"> <el-form-item label="物料分类" required>
<el-select v-model="form.category_id" style="width: 100%"> <el-select v-model="form.category_id" style="width: 100%">
<el-option v-for="category in categories" :key="category.id" :label="`${category.code} - ${category.name}`" :value="category.id" /> <el-option v-for="category in categories" :key="category.id" :label="`${category.code} - ${category.name}`" :value="category.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="编码前缀"><el-input v-model="form.prefix" /></el-form-item> <el-form-item label="编码前缀" required><el-input v-model="form.prefix" /></el-form-item>
<el-form-item label="流水号位数"><el-input-number v-model="form.sequence_length" :min="1" :max="12" /></el-form-item> <el-form-item label="流水号位数"><el-input-number v-model="form.sequence_length" :min="1" :max="12" /></el-form-item>
<el-form-item label="下一流水号"> <el-form-item label="下一流水号">
<el-input-number v-model="form.next_sequence" :min="1" /> <el-input-number v-model="form.next_sequence" :min="1" />
@ -165,6 +165,15 @@ async function openEdit(row) {
} }
async function save() { async function save() {
// :rules 在本项目不生效,校验一律手写。编码规则的必填项与其他 tab 不同
// (分类 + 前缀,见 SaveCodeRule),所以按 tab 分支。
if (activeTab.value === 'rules') {
if (!form.category_id) { ElMessage.warning('请选择物料分类'); return }
if (!form.prefix.trim()) { ElMessage.warning('请填写编码前缀'); return }
} else {
if (!form.code.trim()) { ElMessage.warning('请填写编码'); return }
if (!form.name.trim()) { ElMessage.warning('请填写名称'); return }
}
saving.value = true saving.value = true
try { try {
if (editingId.value) await current.value.api.update(editingId.value, form) if (editingId.value) await current.value.api.update(editingId.value, form)

View File

@ -0,0 +1,126 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Organizations from './Organizations.vue'
import { organizationsApi } from '../../api/organizations'
vi.mock('../../api/organizations', () => ({
organizationsApi: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn()
}
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。
const SelectStub = {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = {
props: ['label', 'value'],
template: '<span class="option">{{ label }}</span>'
}
function mountPage() {
return mount(Organizations, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub }
}
})
}
// 表单项没有 placeholder,按 label 文本定位输入框。
function inputByLabel(wrapper, label) {
const item = wrapper.findAll('.el-form-item').find(
node => node.find('.el-form-item__label').text() === label
)
return item.find('input')
}
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
async function openCreateDialog(wrapper) {
await findButton(wrapper, '新增组织').trigger('click')
await flushPromises()
}
async function clickSave(wrapper) {
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
}
describe('组织表单必填校验', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
organizationsApi.list.mockReset().mockResolvedValue({ data: [] })
organizationsApi.create.mockReset().mockResolvedValue({ data: {} })
organizationsApi.update.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('编码和名称都留空时提示组织编码', async () => {
const wrapper = mountPage()
await flushPromises()
await openCreateDialog(wrapper)
await clickSave(wrapper)
expect(organizationsApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写组织编码')
wrapper.unmount()
})
test('只填了编码时提示组织名称', async () => {
const wrapper = mountPage()
await flushPromises()
await openCreateDialog(wrapper)
await inputByLabel(wrapper, '组织编码').setValue('ORG-001')
await clickSave(wrapper)
expect(organizationsApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写组织名称')
wrapper.unmount()
})
test('只填空格不算填过', async () => {
const wrapper = mountPage()
await flushPromises()
await openCreateDialog(wrapper)
await inputByLabel(wrapper, '组织编码').setValue(' ')
await inputByLabel(wrapper, '组织名称').setValue('默认公司组织')
await clickSave(wrapper)
expect(organizationsApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写组织编码')
wrapper.unmount()
})
test('时区留空不影响提交', async () => {
const wrapper = mountPage()
await flushPromises()
await openCreateDialog(wrapper)
await inputByLabel(wrapper, '组织编码').setValue('ORG-002')
await inputByLabel(wrapper, '组织名称').setValue('华东仓')
await inputByLabel(wrapper, '时区').setValue('')
await clickSave(wrapper)
expect(warnSpy).not.toHaveBeenCalled()
expect(organizationsApi.create).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})

View File

@ -17,8 +17,8 @@
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑组织' : '新增组织'" width="480px"> <el-dialog v-model="dialogVisible" :title="editingId ? '编辑组织' : '新增组织'" width="480px">
<el-form :model="form" label-width="100px"> <el-form :model="form" label-width="100px">
<el-form-item label="组织编码"><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item> <el-form-item label="组织编码" required><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item>
<el-form-item label="组织名称"><el-input v-model="form.name" /></el-form-item> <el-form-item label="组织名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="时区"><el-input v-model="form.default_timezone" /></el-form-item> <el-form-item label="时区"><el-input v-model="form.default_timezone" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status" style="width: 100%"><el-option label="启用" value="active" /><el-option label="停用" value="inactive" /></el-select></el-form-item> <el-form-item label="状态"><el-select v-model="form.status" style="width: 100%"><el-option label="启用" value="active" /><el-option label="停用" value="inactive" /></el-select></el-form-item>
</el-form> </el-form>
@ -42,7 +42,12 @@ const form = reactive({ code: '', name: '', default_timezone: 'Asia/Shanghai', s
async function load() { loading.value = true; try { const response = await organizationsApi.list(); rows.value = response.data || [] } finally { loading.value = false } } async function load() { loading.value = true; try { const response = await organizationsApi.list(); rows.value = response.data || [] } finally { loading.value = false } }
function openCreate() { editingId.value = null; Object.assign(form, { code: '', name: '', default_timezone: 'Asia/Shanghai', status: 'active' }); dialogVisible.value = true } function openCreate() { editingId.value = null; Object.assign(form, { code: '', name: '', default_timezone: 'Asia/Shanghai', status: 'active' }); dialogVisible.value = true }
function openEdit(row) { editingId.value = row.id; Object.assign(form, row); dialogVisible.value = true } function openEdit(row) { editingId.value = row.id; Object.assign(form, row); dialogVisible.value = true }
async function save() { saving.value = true; try { if (editingId.value) await organizationsApi.update(editingId.value, form); else await organizationsApi.create(form); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } } async function save() {
if (!form.code.trim()) { ElMessage.warning('请填写组织编码'); return }
if (!form.name.trim()) { ElMessage.warning('请填写组织名称'); return }
saving.value = true
try { if (editingId.value) await organizationsApi.update(editingId.value, form); else await organizationsApi.create(form); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false }
}
onMounted(load) onMounted(load)
</script> </script>

View File

@ -1,6 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus' import ElementPlus, { ElMessage } from 'element-plus'
import { beforeEach, describe, expect, test, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import Roles from './Roles.vue' import Roles from './Roles.vue'
import { organizationsApi } from '../../api/organizations' import { organizationsApi } from '../../api/organizations'
@ -42,3 +42,88 @@ describe('角色与权限页面', () => {
wrapper.unmount() wrapper.unmount()
}) })
}) })
describe('角色名称必填校验', () => {
let warnSpy
function mountRoles() {
return mount(Roles, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true }
}
})
}
// 表单项没有 placeholder,按 label 文本定位输入框。
function nameInput(wrapper) {
return wrapper.findAll('.el-form-item')
.find(node => node.find('.el-form-item__label').text() === '角色名称')
.find('input')
}
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
organizationsApi.listRoles.mockResolvedValue({
data: [{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] }]
})
organizationsApi.listPermissions.mockResolvedValue({ data: [] })
organizationsApi.updateRole.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('角色名称清空后保存被拦下', async () => {
const wrapper = mountRoles()
await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await flushPromises()
await nameInput(wrapper).setValue('')
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
expect(organizationsApi.updateRole).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写角色名称')
wrapper.unmount()
})
test('角色名称只填空格同样被拦下', async () => {
const wrapper = mountRoles()
await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await flushPromises()
await nameInput(wrapper).setValue(' ')
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
expect(organizationsApi.updateRole).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写角色名称')
wrapper.unmount()
})
test('角色名称正常时允许保存', async () => {
const wrapper = mountRoles()
await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await flushPromises()
await nameInput(wrapper).setValue('仓储主管')
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
expect(warnSpy).not.toHaveBeenCalled()
expect(organizationsApi.updateRole).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})

View File

@ -31,7 +31,7 @@
<el-dialog v-model="dialogVisible" :title="`编辑角色 - ${editingRole?.name || ''}`" width="720px"> <el-dialog v-model="dialogVisible" :title="`编辑角色 - ${editingRole?.name || ''}`" width="720px">
<el-form :model="form" label-width="90px"> <el-form :model="form" label-width="90px">
<el-form-item label="角色名称"><el-input v-model="form.name" /></el-form-item> <el-form-item label="角色名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item> <el-form-item label="启用状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
<el-form-item label="功能权限"> <el-form-item label="功能权限">
<el-checkbox-group v-model="form.permission_codes" class="permission-groups"> <el-checkbox-group v-model="form.permission_codes" class="permission-groups">
@ -96,6 +96,7 @@ function openEdit(role) {
async function saveRole() { async function saveRole() {
if (!editingRole.value) return if (!editingRole.value) return
if (!form.name.trim()) { ElMessage.warning('请填写角色名称'); return }
saving.value = true saving.value = true
try { try {
await organizationsApi.updateRole(editingRole.value.id, form) await organizationsApi.updateRole(editingRole.value.id, form)

View File

@ -0,0 +1,125 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import PartnerList from './PartnerList.vue'
import { partnersApi } from '../../api/partners'
vi.mock('../../api/partners', () => ({
partnersApi: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn()
}
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。
const SelectStub = {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = {
props: ['label', 'value'],
template: '<span class="option">{{ label }}</span>'
}
function mountPartner(role = 'supplier', title = '供应商') {
return mount(PartnerList, {
props: { role, title },
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub }
}
})
}
// 表单项没有 placeholder,按 label 文本定位输入框。
function inputByLabel(wrapper, label) {
const item = wrapper.findAll('.el-form-item').find(
node => node.find('.el-form-item__label').text() === label
)
return item.find('input')
}
function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
async function openCreateDialog(wrapper, title) {
await findButton(wrapper, `新增${title}`).trigger('click')
await flushPromises()
}
async function clickSave(wrapper) {
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
}
describe('伙伴表单必填校验', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
partnersApi.list.mockReset().mockResolvedValue({ data: [] })
partnersApi.create.mockReset().mockResolvedValue({ data: {} })
partnersApi.update.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('伙伴编码和名称都留空时提示伙伴编码', async () => {
const wrapper = mountPartner()
await flushPromises()
await openCreateDialog(wrapper, '供应商')
await clickSave(wrapper)
expect(partnersApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写伙伴编码')
wrapper.unmount()
})
test('只填了伙伴编码时提示企业名称', async () => {
const wrapper = mountPartner()
await flushPromises()
await openCreateDialog(wrapper, '供应商')
await inputByLabel(wrapper, '伙伴编码').setValue('SUP-001')
await clickSave(wrapper)
expect(partnersApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写企业名称')
wrapper.unmount()
})
test('统一社会信用代码等选填项留空不影响提交', async () => {
const wrapper = mountPartner()
await flushPromises()
await openCreateDialog(wrapper, '供应商')
await inputByLabel(wrapper, '伙伴编码').setValue('SUP-002')
await inputByLabel(wrapper, '企业名称').setValue('某某钢材有限公司')
await clickSave(wrapper)
expect(warnSpy).not.toHaveBeenCalled()
expect(partnersApi.create).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
test('客户页同样拦截空的企业名称', async () => {
const wrapper = mountPartner('customer', '客户')
await flushPromises()
await openCreateDialog(wrapper, '客户')
await inputByLabel(wrapper, '伙伴编码').setValue('CUS-001')
await clickSave(wrapper)
expect(partnersApi.create).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith('请填写企业名称')
wrapper.unmount()
})
})

View File

@ -17,8 +17,8 @@
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px"> <el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
<el-form :model="form" label-width="130px"> <el-form :model="form" label-width="130px">
<el-form-item label="伙伴编码"><el-input v-model="form.partner_code" :disabled="Boolean(editingId)" /></el-form-item> <el-form-item label="伙伴编码" required><el-input v-model="form.partner_code" :disabled="Boolean(editingId)" /></el-form-item>
<el-form-item label="企业名称"><el-input v-model="form.legal_name" /></el-form-item> <el-form-item label="企业名称" required><el-input v-model="form.legal_name" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="form.unified_credit_code" /></el-form-item> <el-form-item label="统一社会信用代码"><el-input v-model="form.unified_credit_code" /></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item> <el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item> <el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item>
@ -64,7 +64,12 @@ function payload() {
[`${props.role}_status`]: form.status [`${props.role}_status`]: form.status
} }
} }
async function save() { saving.value = true; try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } } async function save() {
if (!form.partner_code.trim()) { ElMessage.warning('请填写伙伴编码'); return }
if (!form.legal_name.trim()) { ElMessage.warning('请填写企业名称'); return }
saving.value = true
try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false }
}
watch(() => props.role, load) watch(() => props.role, load)
onMounted(load) onMounted(load)
</script> </script>

View File

@ -64,6 +64,9 @@ type InboundOrder struct {
Remark string `json:"remark"` Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
// Status 不落库:inbound_orders 表没有 status 列,状态由查询层统一写死为 "completed"
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
// 将来引入状态机时再改为真实读写。
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Items []InboundItem `json:"items,omitempty"` Items []InboundItem `json:"items,omitempty"`
} }
@ -90,6 +93,9 @@ type OutboundOrder struct {
Remark string `json:"remark"` Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
// Status 不落库:outbound_orders 表没有 status 列,状态由查询层统一写死为 "completed"
// (单据创建即完成,当前没有中间态)。保留字段是为了稳定 JSON 契约,
// 将来引入状态机时再改为真实读写。
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Items []OutboundItem `json:"items,omitempty"` Items []OutboundItem `json:"items,omitempty"`
} }
@ -331,6 +337,8 @@ const (
// MaterialApplication represents a material application record // MaterialApplication represents a material application record
type MaterialApplication struct { type MaterialApplication struct {
ID int64 `json:"id"` ID int64 `json:"id"`
// ApplicationNo 不落库,由主键派生(同入库单/出库单的单号规则),查询时统一补齐。
ApplicationNo string `json:"application_no,omitempty"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Purpose string `json:"purpose,omitempty"` Purpose string `json:"purpose,omitempty"`

View File

@ -68,6 +68,7 @@ func (r *ApplicationRepository) GetByID(id int64) (*model.MaterialApplication, e
return nil, err return nil, err
} }
app.ApplicationNo = model.OrderNo("AP", app.ID)
return &app, nil return &app, nil
} }
@ -155,6 +156,7 @@ func (r *ApplicationRepository) ListAll(status string, userID int64, limit, offs
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
app.ApplicationNo = model.OrderNo("AP", app.ID)
apps = append(apps, app) apps = append(apps, app)
} }
return apps, total, nil return apps, total, nil
@ -194,9 +196,11 @@ func (r *ApplicationRepository) GetByIDWithDetails(id int64) (*model.MaterialApp
return r.GetByID(id) return r.GetByID(id)
} }
// outbound_orders 没有 status 列:出库单一经创建即为完成态,
// 这里与手工出库路径(OutboundRepo.CreateTx)写入同样的列。
func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error { func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver) VALUES (NOW(), ?, ?, ?)`
result, err := database.DB.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status) result, err := database.DB.Exec(query, order.Operator, order.Handler, order.Receiver)
if err != nil { if err != nil {
return err return err
} }
@ -212,9 +216,11 @@ func (r *ApplicationRepository) CreateOutboundOrder(order *model.OutboundOrder)
return nil return nil
} }
// outbound_orders 与 inbound_orders 一样没有 status 列,单据状态由查询层统一写死。
// 这里写入的列必须和手工出库(OutboundRepo.CreateTx)一致,多写一列会让 INSERT 直接失败。
func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error { func (r *ApplicationRepository) CreateOutboundOrderTx(tx *sql.Tx, order *model.OutboundOrder) error {
query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver, status) VALUES (NOW(), ?, ?, ?, ?)` query := `INSERT INTO outbound_orders (outbound_date, operator, handler, receiver) VALUES (NOW(), ?, ?, ?)`
result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver, order.Status) result, err := tx.Exec(query, order.Operator, order.Handler, order.Receiver)
if err != nil { if err != nil {
return err return err
} }

View File

@ -95,6 +95,7 @@ func (r *InboundRepo) GetByID(id int64) (*model.InboundOrder, error) {
order.Items = append(order.Items, item) order.Items = append(order.Items, item)
} }
// 状态不落库:inbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。
order.Status = "completed" order.Status = "completed"
return order, nil return order, nil
} }
@ -139,6 +140,7 @@ func (r *InboundRepo) List(search string, limit, offset int) ([]model.InboundOrd
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// 同上:状态在查询层给出,列表里一并补上。
order.Status = "completed" order.Status = "completed"
orders = append(orders, order) orders = append(orders, order)
} }

View File

@ -95,6 +95,7 @@ func (r *OutboundRepo) GetByID(id int64) (*model.OutboundOrder, error) {
order.Items = append(order.Items, item) order.Items = append(order.Items, item)
} }
// 状态不落库:outbound_orders 没有 status 列,统一在这里给出。引入状态机时改为真实读写。
order.Status = "completed" order.Status = "completed"
return order, nil return order, nil
} }
@ -139,6 +140,7 @@ func (r *OutboundRepo) List(search string, limit, offset int) ([]model.OutboundO
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// 同上:状态在查询层给出,列表里一并补上。
order.Status = "completed" order.Status = "completed"
orders = append(orders, order) orders = append(orders, order)
} }

View File

@ -122,8 +122,12 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
if err != nil { if err != nil {
return err return err
} }
// 这里不能靠 err 判断是否回滚:事务内每个 if 里的 err 都是 := 声明的局部变量,
// 闭包捕获到的外层 err 始终为 nil,任何一步失败都不会回滚,事务和它持有的行锁
// 会一直挂在连接上不释放。改用标志位,只要没走到提交就一定回滚。
committed := false
defer func() { defer func() {
if err != nil { if !committed {
tx.Rollback() tx.Rollback()
} }
}() }()
@ -191,6 +195,7 @@ func (s *ApplicationService) Approve(id int64, approverID int64, autoOutbound bo
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return err return err
} }
committed = true
} else { } else {
// Non-auto-outbound: just approve // Non-auto-outbound: just approve
if err := s.appRepo.Approve(id, approverID); err != nil { if err := s.appRepo.Approve(id, approverID); err != nil {

View File

@ -3,6 +3,8 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
@ -31,6 +33,10 @@ func (s *MaterialLogService) LogCreate(material *model.Material, operator string
} }
func (s *MaterialLogService) LogUpdate(material *model.Material, oldMaterial *model.Material, operator string) error { func (s *MaterialLogService) LogUpdate(material *model.Material, oldMaterial *model.Material, operator string) error {
// 上层取「修改后」的物料失败时仍会走到这里,material 为 nil,取字段会 panic
if material == nil {
return nil
}
oldValue, _ := json.Marshal(oldMaterial) oldValue, _ := json.Marshal(oldMaterial)
newValue, _ := json.Marshal(material) newValue, _ := json.Marshal(material)
log := &model.MaterialLog{ log := &model.MaterialLog{
@ -39,11 +45,92 @@ func (s *MaterialLogService) LogUpdate(material *model.Material, oldMaterial *mo
OldValue: string(oldValue), OldValue: string(oldValue),
NewValue: string(newValue), NewValue: string(newValue),
Operator: operator, Operator: operator,
Description: fmt.Sprintf("修改物料:%s", material.Name), Description: updateDescription(material, oldMaterial),
} }
return s.logRepo.Create(log) return s.logRepo.Create(log)
} }
// updateDescription 让日志详情直接说明改了什么,用户不必去比对 old_value/new_value 里的整段 JSON。
// 比对不出任何变化时退回只带物料名的简版。
func updateDescription(material, oldMaterial *model.Material) string {
description := fmt.Sprintf("修改物料:%s", material.Name)
if oldMaterial == nil {
return description
}
changes := materialChanges(oldMaterial, material)
if len(changes) == 0 {
return description
}
return fmt.Sprintf("%s(%s)", description, strings.Join(changes, ";"))
}
// materialChanges 逐字段比对,返回「字段名:旧值 → 新值」形式的变更列表,顺序即展示顺序。
// 分类和基本单位在服务层拿不到名称,如实显示 ID;物料图片只报「已更新」,
// 图片 URL 往往很长,整条塞进描述会把别的变更挤没。
func materialChanges(oldMaterial, newMaterial *model.Material) []string {
fields := []struct {
label string
oldValue string
newValue string
}{
{"物料编码", oldMaterial.MaterialCode, newMaterial.MaterialCode},
{"名称", oldMaterial.Name, newMaterial.Name},
{"规格", oldMaterial.Spec, newMaterial.Spec},
{"厂商型号", derefString(oldMaterial.ManufacturerModel), derefString(newMaterial.ManufacturerModel)},
{"分类", derefID(oldMaterial.CategoryID), derefID(newMaterial.CategoryID)},
{"基本单位", derefID(oldMaterial.BaseUnitID), derefID(newMaterial.BaseUnitID)},
{"单位", oldMaterial.Unit, newMaterial.Unit},
// 作废和恢复也是走这里的:状态一变,日志就写明「状态:启用 → 作废」
{"状态", materialStatusLabel(oldMaterial.Status), materialStatusLabel(newMaterial.Status)},
}
var changes []string
for _, field := range fields {
if field.oldValue != field.newValue {
changes = append(changes, fmt.Sprintf("%s:%s → %s",
field.label, orEmptyValue(field.oldValue), orEmptyValue(field.newValue)))
}
}
if derefString(oldMaterial.MaterialImage) != derefString(newMaterial.MaterialImage) {
changes = append(changes, "物料图片:已更新")
}
return changes
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func derefID(value *int64) string {
if value == nil {
return ""
}
return strconv.FormatInt(*value, 10)
}
// orEmptyValue 把空值显示成「空」,否则会写出「规格: → 6205」这种读不出是新增还是清空的描述。
func orEmptyValue(value string) string {
if value == "" {
return "空"
}
return value
}
// materialStatusLabel 把状态值转成界面用语,日志里直接写 active/obsolete 对用户没有意义。
func materialStatusLabel(status string) string {
switch status {
case "active":
return "启用"
case "obsolete":
return "作废"
default:
return status
}
}
func (s *MaterialLogService) LogDelete(material *model.Material, operator string) error { func (s *MaterialLogService) LogDelete(material *model.Material, operator string) error {
oldValue, _ := json.Marshal(material) oldValue, _ := json.Marshal(material)
log := &model.MaterialLog{ log := &model.MaterialLog{

View File

@ -0,0 +1,91 @@
package service
import (
"strings"
"testing"
"github.com/wmms/wmms/internal/model"
)
func TestUpdateDescriptionReportsWhatChanged(t *testing.T) {
oldMaterial := &model.Material{ID: 7, Name: "轴承", Spec: "6205", Unit: "pcs", Status: "active"}
newMaterial := &model.Material{ID: 7, Name: "轴承", Spec: "6205-2RS", Unit: "件", Status: "active"}
got := updateDescription(newMaterial, oldMaterial)
want := "修改物料:轴承(规格:6205 → 6205-2RS;单位:pcs → 件)"
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
func TestUpdateDescriptionWithoutChangesKeepsNameOnly(t *testing.T) {
oldMaterial := &model.Material{ID: 7, Name: "轴承", Status: "active"}
newMaterial := &model.Material{ID: 7, Name: "轴承", Status: "active"}
got := updateDescription(newMaterial, oldMaterial)
// 没有变化时不能留下一个空的括号
if got != "修改物料:轴承" {
t.Fatalf("unexpected description: %q", got)
}
}
// 作废和恢复复用的也是 LogUpdate,状态变化必须写进描述,
// 否则日志里看不出这是一次作废,只会显示成普通的「修改物料」。
func TestUpdateDescriptionShowsStatusChange(t *testing.T) {
oldMaterial := &model.Material{ID: 7, Name: "轴承", Status: "active"}
newMaterial := &model.Material{ID: 7, Name: "轴承", Status: "obsolete"}
got := updateDescription(newMaterial, oldMaterial)
want := "修改物料:轴承(状态:启用 → 作废)"
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
func TestUpdateDescriptionMarksEmptyValues(t *testing.T) {
oldMaterial := &model.Material{ID: 7, Name: "轴承", Spec: "", Status: "active"}
newMaterial := &model.Material{ID: 7, Name: "轴承", Spec: "6205", Status: "active"}
got := updateDescription(newMaterial, oldMaterial)
want := "修改物料:轴承(规格:空 → 6205)"
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
// 图片 URL 往往很长,整条塞进描述会把其他变更挤没,所以只报「已更新」。
func TestUpdateDescriptionDoesNotInlineImageURL(t *testing.T) {
longURL := "data:image/png;base64," + strings.Repeat("A", 500)
oldMaterial := &model.Material{ID: 7, Name: "轴承", Status: "active"}
newMaterial := &model.Material{ID: 7, Name: "轴承", Status: "active", MaterialImage: &longURL}
got := updateDescription(newMaterial, oldMaterial)
if got != "修改物料:轴承(物料图片:已更新)" {
t.Fatalf("unexpected description: %q", got)
}
if strings.Contains(got, "base64") {
t.Fatalf("image URL must not be inlined: %q", got)
}
}
func TestUpdateDescriptionWithoutOldMaterialKeepsNameOnly(t *testing.T) {
got := updateDescription(&model.Material{ID: 7, Name: "轴承"}, nil)
if got != "修改物料:轴承" {
t.Fatalf("unexpected description: %q", got)
}
}
// 上层取「修改后」的物料失败时会传 nil 进来,nil 解引用会直接 panic。
func TestLogUpdateSkipsWhenMaterialIsMissing(t *testing.T) {
err := NewMaterialLogService().LogUpdate(nil, &model.Material{ID: 7}, "tester")
if err != nil {
t.Fatalf("nil material should be a no-op, got: %v", err)
}
}