diff --git a/docs/api.md b/docs/api.md index fd713341..d5c213a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,453 +1,643 @@ -# Warehouse Material Management System - API Specification +# 仓库物料管理系统 API 文档 -**Base URL:** `/api` +**基础地址**:`/api` --- -## 1. Material APIs +## 阅读说明 -### GET /materials +### 认证 -List all materials with optional filtering. +除 `POST /auth/login` 外,所有接口都要求携带 JWT: -**Query Parameters:** -| Parameter | Type | Required | Description | -|-----------|--------|----------|----------------------------------| -| search | string | No | Search by material code or name | -| category | string | No | Filter by category | +| 请求头 | 值 | +|----------------|-------------------| +| Authorization | `Bearer ` | + +缺少或失效的令牌返回 `401 Unauthorized`。标为「仅管理员」的接口由 `AdminOnly` 中间件拦截,非管理员返回 `403 Forbidden`。 + +### 统一响应结构 + +所有接口共用同一个外层结构: + +| 字段 | 类型 | 说明 | +|---------|---------|--------------------------------------------| +| code | integer | `0` 表示成功;失败时等于 HTTP 状态码 | +| message | string | 提示信息(后端硬编码,目前为英文) | +| data | object | 业务数据;无返回值的接口为 `null` | + +### 分页结构 + +列表接口的 `data` **不是数组**,而是一个分页对象: -**Response Body:** ```json { "code": 0, - "data": [ - { - "id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 100, - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z" - } - ], - "message": "success" + "message": "success", + "data": { + "items": [], + "limit": 20, + "offset": 0, + "total": 0, + "has_more": false + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success +| 字段 | 类型 | 说明 | +|----------|---------|------------------------------------------| +| items | array | 当前页数据 | +| limit | integer | 每页条数,默认 20,**上限 100** | +| offset | integer | 偏移量,默认 0 | +| total | integer | 符合条件的总条数 | +| has_more | boolean | 是否还有下一页 | + +`limit` 传超过 100 的值会被服务端截断为 100,而不是报错。 + +### 本文档覆盖范围 + +本文档详述**物料、入库、出库、库存**四个资源域。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册: + +| 分组 | 路径 | 权限 | +|-------------------------|---------------------------------------------|--------| +| 认证 | `POST /auth/login`、`GET /auth/me`、`POST /auth/logout` | 登录接口公开,其余需登录 | +| 用户 | `/users` | 仅管理员 | +| 组织 | `/organizations` | 仅管理员 | +| 角色 / 权限 | `/roles`、`/permissions` | 仅管理员 | +| 主数据:物料分类 | `/material-categories` | 仅管理员 | +| 主数据:计量单位 | `/units` | 仅管理员 | +| 主数据:编码规则 | `/code-rules` | 仅管理员 | +| 业务伙伴 | `/partners` | 仅管理员 | +| 仓库与库位 | `/warehouses`、`/warehouses/:id/locations` | 仅管理员 | +| 物料申请 | `/applications`、`/applications/my` | 需登录 | +| 操作日志 | `/logs`、`/logs/materials/:materialId` | 需登录 | +| 图片上传 | `POST /upload` | 需登录 | + +--- + +## 1. 物料接口 + +物料是主数据,**不承载任何库存信息**。库存数量和预警阈值分别由库存接口和入库流程维护。 + +### GET /materials + +分页查询物料列表。 + +**查询参数:** + +| 参数 | 类型 | 必填 | 说明 | +|----------|---------|------|----------------------------| +| search | string | 否 | 按物料编码或名称模糊搜索 | +| category | string | 否 | **当前未生效**,服务端接收但未用于过滤 | +| limit | integer | 否 | 每页条数,默认 20,上限 100 | +| offset | integer | 否 | 偏移量,默认 0 | + +> 该接口**不按物料状态过滤**,已作废的物料同样会出现在结果里,调用方需要自己判断 `status` 字段。 + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): + +```json +{ + "code": 0, + "message": "success", + "data": { + "items": [ + { + "id": 1, + "organization_id": 1, + "material_code": "A01-0001", + "name": "内六角螺栓", + "material_image": "/uploads/bolt.png", + "spec": "M8x30", + "manufacturer_model": "GB/T 70.1", + "category_id": 12, + "base_unit_id": 3, + "unit": "个", + "status": "active", + "version": 1, + "created_at": "2026-01-01T08:00:00Z", + "updated_at": "2026-01-01T08:00:00Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } +} +``` + +`material_image`、`spec`、`manufacturer_model`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。 + +**状态码:** + +- `200 OK` — 成功 +- `401 Unauthorized` — 未登录 --- ### GET /materials/:id -Get a single material by ID. +查询单个物料。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-----------|---------|----------|------------------| -| id | integer | Yes | Material ID | +**路径参数:** -**Response Body:** -```json -{ - "code": 0, - "data": { - "id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 100, - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z" - }, - "message": "success" -} -``` +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 物料 ID | -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Material not found +**响应:** `data` 为单个物料对象,字段同 `GET /materials`。 + +**状态码:** + +- `200 OK` — 成功 +- `400 Bad Request` — 物料 ID 非法 +- `404 Not Found` — 物料不存在 --- ### POST /materials -Create a new material. +创建物料。 + +**请求体:** -**Request Body:** ```json { - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 100 + "material_code": "A01-0001", + "name": "内六角螺栓", + "spec": "M8x30", + "manufacturer_model": "GB/T 70.1", + "category_id": 12, + "base_unit_id": 3, + "unit": "个", + "material_image": "/uploads/bolt.png" } ``` -| Field | Type | Required | Description | -|----------------|---------|----------|--------------------------------------| -| material_code | string | Yes | Unique material code | -| name | string | Yes | Material name | -| spec | string | No | Specification | -| category | string | No | Material category | -| unit | string | No | Unit of measure (default: pcs) | -| warning_stock | integer | No | Warning stock threshold (default: 0) | +| 字段 | 类型 | 必填 | 说明 | +|-------------------|---------|------|--------------------------------------| +| material_code | string | 是 | 物料编码,组织内唯一 | +| name | string | 是 | 物料名称 | +| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 | +| spec | string | 否 | 规格 | +| manufacturer_model| string | 否 | 厂家型号 | +| base_unit_id | integer | 否 | 基本计量单位 ID | +| unit | string | 否 | 单位名称(冗余展示字段) | +| material_image | string | 否 | 图片地址 | +| organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 | + +**响应:** `201 Created`,`data` 为创建后的物料对象。 -**Response Body:** ```json { "code": 0, - "data": { - "id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 100, - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z" - }, - "message": "Material created successfully" + "message": "Material created successfully", + "data": { "id": 1, "material_code": "A01-0001", "name": "内六角螺栓", "status": "active" } } ``` -**HTTP Status Codes:** -- `201 Created` - Material created successfully -- `400 Bad Request` - Invalid request body or material code already exists +**状态码:** + +- `201 Created` — 创建成功 +- `400 Bad Request` — 请求体非法、物料编码已存在,或 `category_id` 不是有效的小类类型 --- ### PUT /materials/:id -Update an existing material. +更新物料。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-----------|---------|----------|------------------| -| id | integer | Yes | Material ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 物料 ID | + +**请求体:** -**Request Body:** ```json { - "material_code": "M001", - "name": "Steel Plate", - "spec": "12mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 150 + "material_code": "A01-0001", + "name": "内六角螺栓(镀锌)", + "spec": "M8x35", + "category_id": 12, + "unit": "个" } ``` -All fields are optional. Only provided fields will be updated. +| 字段 | 类型 | 必填 | 说明 | +|-------------------|---------|------|--------------------------| +| category_id | integer | **是** | 即使不修改分类也必须传 | +| material_code | string | 否 | 物料编码 | +| name | string | 否 | 物料名称 | +| spec | string | 否 | 规格 | +| manufacturer_model| string | 否 | 厂家型号 | +| base_unit_id | integer | 否 | 基本计量单位 ID | +| unit | string | 否 | 单位名称 | +| material_image | string | 否 | 图片地址 | +| status | string | 否 | 物料状态 | -**Response Body:** -```json -{ - "code": 0, - "data": { - "id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "12mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "warning_stock": 150, - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-02T10:00:00Z" - }, - "message": "Material updated successfully" -} -``` +> `category_id` 在更新接口里同样是必填项。只改名称而不传分类会返回 `400`。 -**HTTP Status Codes:** -- `200 OK` - Material updated successfully -- `400 Bad Request` - Invalid request body -- `404 Not Found` - Material not found +**响应:** `200 OK`,`data` 为更新后的物料对象。 + +**状态码:** + +- `200 OK` — 更新成功 +- `400 Bad Request` — 请求体非法或缺少 `category_id` +- `404 Not Found` — 物料不存在 --- -### DELETE /materials/:id +### POST /materials/:id/obsolete -Delete a material. +作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-----------|---------|----------|------------------| -| id | integer | Yes | Material ID | +> 注意作废**不会**把物料从 `GET /materials` 的列表和物料下拉中移除——那个接口不过滤状态。因此作废物料依然出现在入库、出库、物料申请的物料选择器里,服务端也不会阻止给它入库。物料的可用性判断需要调用方自己看 `status` 字段。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 物料 ID | + +**请求体:** 无。 + +**响应:** -**Response Body:** ```json { "code": 0, - "data": null, - "message": "Material deleted successfully" + "message": "Material obsoleted successfully", + "data": null } ``` -**HTTP Status Codes:** -- `200 OK` - Material deleted successfully -- `404 Not Found` - Material not found +**状态码:** + +- `200 OK` — 作废成功 +- `400 Bad Request` — 物料 ID 非法 +- `404 Not Found` — 物料不存在 +- `409 Conflict` — **仍有库存(在库、预留、锁定或在途数量不为 0)**,需要先出库清零再作废 + +> 不再补货的物料走作废退役,而不是把预警阈值设为 0。作废要求库存为零,所以典型流程是:出库清零 → 作废。 --- -## 2. Inbound APIs +### POST /materials/:id/restore + +撤销售废,把物料恢复为可用状态。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 物料 ID | + +**请求体:** 无。 + +**响应:** + +```json +{ + "code": 0, + "message": "Material restored successfully", + "data": null +} +``` + +**状态码:** + +- `200 OK` — 恢复成功 +- `400 Bad Request` — 物料 ID 非法 +- `404 Not Found` — 物料不存在 + +--- + +## 2. 入库接口 + +入库是物料**进入库存视野**的唯一入口:物料的第一行库存记录和第一个预警阈值都在这里产生。入库成功后,服务端会通过 WebSocket 广播库存变更。 ### GET /inbound -List all inbound orders. +分页查询入库单列表。 -**Query Parameters:** -| Parameter | Type | Required | Description | -|-----------|--------|----------|----------------------------------| -| search | string | No | Search by handler or supplier | +**查询参数:** + +| 参数 | 类型 | 必填 | 说明 | +|--------|---------|------|----------------------------| +| search | string | 否 | 按经手人或供应商模糊搜索 | +| limit | integer | 否 | 每页条数,默认 20,上限 100 | +| offset | integer | 否 | 偏移量,默认 0 | + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): -**Response Body:** ```json { "code": 0, - "data": [ - { - "id": 1, - "handler": "John Doe", - "supplier": "ABC Steel Co.", - "status": "completed", - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z" - } - ], - "message": "success" + "message": "success", + "data": { + "items": [ + { + "id": 1, + "inbound_date": "2026-01-01T08:00:00Z", + "handler": "张三", + "supplier": "ABC 钢材", + "status": "completed", + "created_at": "2026-01-01T08:00:00Z", + "updated_at": "2026-01-01T08:00:00Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success +> 单据的 `status` 字段目前是服务端**硬编码的固定值 `"completed"`**(数据库里没有对应的状态列),所有入库单都返回同一个值,不能用于区分单据阶段。出库单同理。 + +**状态码:** `200 OK`、`401 Unauthorized` --- ### GET /inbound/:id -Get a single inbound order with its items. +查询单个入库单,**包含明细行**。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-----------|---------|----------|-------------------| -| id | integer | Yes | Inbound order ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|------------| +| id | integer | 是 | 入库单 ID | + +**响应:** -**Response Body:** ```json { "code": 0, + "message": "success", "data": { "id": 1, - "handler": "John Doe", - "supplier": "ABC Steel Co.", + "inbound_date": "2026-01-01T08:00:00Z", + "handler": "张三", + "supplier": "ABC 钢材", "status": "completed", "created_at": "2026-01-01T08:00:00Z", "updated_at": "2026-01-01T08:00:00Z", "items": [ { "id": 1, + "inbound_order_id": 1, "material_id": 1, - "material_code": "M001", - "material_name": "Steel Plate", + "material_code": "A01-0001", + "material_name": "内六角螺栓", "quantity": 500 } ] - }, - "message": "success" + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Inbound order not found +**状态码:** + +- `200 OK` — 成功 +- `400 Bad Request` — 入库单 ID 非法 +- `404 Not Found` — 入库单不存在 --- ### POST /inbound -Create a new inbound order. +创建入库单。创建时库存立即增加,同一个事务内写入预警阈值。 + +**请求体:** -**Request Body:** ```json { - "handler": "John Doe", - "supplier": "ABC Steel Co.", + "handler": "张三", + "supplier": "ABC 钢材", "items": [ - { "material_id": 1, "quantity": 500 }, + { "material_id": 1, "quantity": 500, "warning_stock": 100 }, { "material_id": 2, "quantity": 200 } ] } ``` -| Field | Type | Required | Description | -|----------|--------|----------|------------------------| -| handler | string | Yes | Handler name | -| supplier | string | Yes | Supplier name | -| items | array | Yes | List of inbound items | +| 字段 | 类型 | 必填 | 说明 | +|----------|--------|------|--------------------| +| handler | string | 是 | 经手人 | +| supplier | string | 是 | 供应商 | +| items | array | 是 | 明细,至少 1 行 | -**Items Array Structure:** -| Field | Type | Required | Description | -|-------------|---------|----------|--------------------------| -| material_id | integer | Yes | Material ID | -| quantity | integer | Yes | Inbound quantity (> 0) | +**明细字段:** + +| 字段 | 类型 | 必填 | 说明 | +|---------------|---------|------|--------------------------------------------------------| +| material_id | integer | 是 | 物料 ID | +| quantity | integer | 是 | 入库数量,必须大于 0 | +| warning_stock | number | 否 | 为该物料设置的预警阈值;**不传表示不改动已存的阈值** | + +> `warning_stock` 与库存更新在同一个事务里落库,保证物料的第一个阈值和第一行库存同时生效。编辑旧入库单时**不传**这个字段(而不是传 `0`),才不会把别处刚调整好的阈值覆盖回去。 +> +> 本接口**不支持备注字段**。前端即便提交了 `remark`,服务端也会忽略。 + +**响应:** `201 Created`,`data` 为创建后的入库单,结构同 `GET /inbound/:id`。 + +**状态码:** + +- `201 Created` — 创建成功 +- `400 Bad Request` — 请求体非法、物料不存在,或 `warning_stock` 为负数 + +--- + +### PUT /inbound/:id + +更新入库单。服务端会先冲销原明细的库存,再按新明细重新入库,两步在同一事务内完成。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 入库单 ID | + +**请求体:** 字段同 `POST /inbound`。`warning_stock` 同样遵循「不传即不改动」的语义。 + +**响应:** `200 OK`,`data` 为更新后的入库单。 + +**状态码:** + +- `200 OK` — 更新成功 +- `400 Bad Request` — 请求体非法、物料不存在,或 `warning_stock` 为负数 +- `500 Internal Server Error` — 入库单不存在等错误 + +--- + +### DELETE /inbound/:id + +删除入库单。同时**冲销该单带来的库存增加**并删除对应的库存流水。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|----------| +| id | integer | 是 | 入库单 ID | + +**响应:** -**Response Body:** ```json { "code": 0, - "data": { - "id": 1, - "handler": "John Doe", - "supplier": "ABC Steel Co.", - "status": "completed", - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z", - "items": [ - { - "id": 1, - "material_id": 1, - "material_code": "M001", - "material_name": "Steel Plate", - "quantity": 500 - } - ] - }, - "message": "Inbound order created successfully" + "message": "Inbound order deleted successfully", + "data": null } ``` -**HTTP Status Codes:** -- `201 Created` - Inbound order created successfully -- `400 Bad Request` - Invalid request body or material not found +**状态码:** + +- `200 OK` — 删除成功 +- `400 Bad Request` — 入库单 ID 非法 +- `500 Internal Server Error` — 入库单不存在等错误 --- ### GET /inbound/materials/:materialId -Get inbound records for a specific material. +查询某个物料的入库记录。**该接口不分页**,`data` 直接是数组。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-------------|---------|----------|-----------------| -| materialId | integer | Yes | Material ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------------|---------|------|----------| +| materialId | integer | 是 | 物料 ID | + +**响应:** -**Response Body:** ```json { "code": 0, + "message": "success", "data": [ { "id": 1, "inbound_id": 1, - "handler": "John Doe", - "supplier": "ABC Steel Co.", + "handler": "张三", + "supplier": "ABC 钢材", "quantity": 500, "created_at": "2026-01-01T08:00:00Z" } - ], - "message": "success" + ] } ``` -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Material not found +**状态码:** `200 OK`、`400 Bad Request` --- -## 3. Outbound APIs +## 3. 出库接口 + +出库会校验可用库存,**库存不足时整单失败**。出库成功后同样会广播 WebSocket 库存变更。 ### GET /outbound -List all outbound orders. +分页查询出库单列表。 -**Query Parameters:** -| Parameter | Type | Required | Description | -|-----------|--------|----------|----------------------------------| -| search | string | No | Search by handler or receiver | +**查询参数:** + +| 参数 | 类型 | 必填 | 说明 | +|--------|---------|------|----------------------------| +| search | string | 否 | 按经手人或领用人模糊搜索 | +| limit | integer | 否 | 每页条数,默认 20,上限 100 | +| offset | integer | 否 | 偏移量,默认 0 | + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): -**Response Body:** ```json { "code": 0, - "data": [ - { - "id": 1, - "handler": "Jane Smith", - "receiver": "XYZ Factory", - "status": "completed", - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z" - } - ], - "message": "success" + "message": "success", + "data": { + "items": [ + { + "id": 1, + "outbound_date": "2026-01-01T08:00:00Z", + "handler": "李四", + "receiver": "装配车间", + "status": "completed", + "created_at": "2026-01-01T08:00:00Z", + "updated_at": "2026-01-01T08:00:00Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success +**状态码:** `200 OK`、`401 Unauthorized` --- ### GET /outbound/:id -Get a single outbound order with its items. +查询单个出库单,**包含明细行**。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-----------|---------|----------|-------------------| -| id | integer | Yes | Outbound order ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|------------| +| id | integer | 是 | 出库单 ID | + +**响应:** -**Response Body:** ```json { "code": 0, + "message": "success", "data": { "id": 1, - "handler": "Jane Smith", - "receiver": "XYZ Factory", + "outbound_date": "2026-01-01T08:00:00Z", + "handler": "李四", + "receiver": "装配车间", "status": "completed", "created_at": "2026-01-01T08:00:00Z", "updated_at": "2026-01-01T08:00:00Z", "items": [ { "id": 1, + "outbound_order_id": 1, "material_id": 1, - "material_code": "M001", - "material_name": "Steel Plate", + "material_code": "A01-0001", + "material_name": "内六角螺栓", "quantity": 100 } ] - }, - "message": "success" + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Outbound order not found +**状态码:** + +- `200 OK` — 成功 +- `400 Bad Request` — 出库单 ID 非法 +- `404 Not Found` — 出库单不存在 --- ### POST /outbound -Create a new outbound order. +创建出库单。库存立即扣减,库存不足时整单失败。 + +**请求体:** -**Request Body:** ```json { - "handler": "Jane Smith", - "receiver": "XYZ Factory", + "handler": "李四", + "receiver": "装配车间", "items": [ { "material_id": 1, "quantity": 100 }, { "material_id": 2, "quantity": 50 } @@ -455,244 +645,393 @@ Create a new outbound order. } ``` -| Field | Type | Required | Description | -|----------|--------|----------|-------------------------| -| handler | string | Yes | Handler name | -| receiver | string | Yes | Receiver name | -| items | array | Yes | List of outbound items | +| 字段 | 类型 | 必填 | 说明 | +|----------|--------|------|------------------| +| handler | string | 是 | 经手人 | +| receiver | string | 是 | 领用人 | +| items | array | 是 | 明细,至少 1 行 | -**Items Array Structure:** -| Field | Type | Required | Description | -|-------------|---------|----------|---------------------------| -| material_id | integer | Yes | Material ID | -| quantity | integer | Yes | Outbound quantity (> 0) | +**明细字段:** + +| 字段 | 类型 | 必填 | 说明 | +|-------------|---------|------|----------------------| +| material_id | integer | 是 | 物料 ID | +| quantity | integer | 是 | 出库数量,必须大于 0 | + +**响应:** `201 Created`,`data` 为创建后的出库单,结构同 `GET /outbound/:id`。 + +**状态码:** + +- `201 Created` — 创建成功 +- `400 Bad Request` — 请求体非法、物料不存在,或**库存不足** + +--- + +### PUT /outbound/:id + +更新出库单。服务端先冲销原明细的库存扣减,再按新明细重新扣减,两步在同一事务内完成。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|------------| +| id | integer | 是 | 出库单 ID | + +**请求体:** 字段同 `POST /outbound`。 + +**响应:** `200 OK`,`data` 为更新后的出库单。 + +**状态码:** + +- `200 OK` — 更新成功 +- `400 Bad Request` — 请求体非法、物料不存在,或库存不足 +- `500 Internal Server Error` — 出库单不存在等错误 + +--- + +### DELETE /outbound/:id + +删除出库单。同时**把已扣减的库存加回来**并删除对应的库存流水。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|---------|------|------------| +| id | integer | 是 | 出库单 ID | + +**响应:** -**Response Body:** ```json { "code": 0, - "data": { - "id": 1, - "handler": "Jane Smith", - "receiver": "XYZ Factory", - "status": "completed", - "created_at": "2026-01-01T08:00:00Z", - "updated_at": "2026-01-01T08:00:00Z", - "items": [ - { - "id": 1, - "material_id": 1, - "material_code": "M001", - "material_name": "Steel Plate", - "quantity": 100 - } - ] - }, - "message": "Outbound order created successfully" + "message": "Outbound order deleted successfully", + "data": null } ``` -**HTTP Status Codes:** -- `201 Created` - Outbound order created successfully -- `400 Bad Request` - Invalid request body, material not found, or insufficient stock +**状态码:** + +- `200 OK` — 删除成功 +- `400 Bad Request` — 出库单 ID 非法 +- `500 Internal Server Error` — 出库单不存在等错误 --- ### GET /outbound/materials/:materialId -Get outbound records for a specific material. +查询某个物料的出库记录。**该接口不分页**,`data` 直接是数组。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-------------|---------|----------|-----------------| -| materialId | integer | Yes | Material ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------------|---------|------|----------| +| materialId | integer | 是 | 物料 ID | + +**响应:** -**Response Body:** ```json { "code": 0, + "message": "success", "data": [ { "id": 1, "outbound_id": 1, - "handler": "Jane Smith", - "receiver": "XYZ Factory", + "handler": "李四", + "receiver": "装配车间", "quantity": 100, "created_at": "2026-01-01T08:00:00Z" } - ], - "message": "success" + ] } ``` -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Material not found +**状态码:** `200 OK`、`400 Bad Request` --- -## 4. Stock APIs +## 4. 库存接口 + +### 库存状态的判定规则 + +整套预警逻辑只有一句话: + +``` +参与预警 = 启用 AND 阈值 > 0 +报警 = 参与预警 AND 现有库存 <= 阈值 +``` + +展开到接口行为上: + +- **库存查询(`GET /stock`)只列「入过库」的物料**。判断依据是存在库存记录行,而不是库存数量大于 0——所以**已被领用到 0 的物料仍然会列出**,而只存在于主数据、从未入库的物料不会出现。已作废的物料也不列出。 +- **阈值没设过(为 0)的物料不会报警**,即使库存已经是 0。没有设定期望值,就不替使用者判断"该补货了"。 +- **阈值被停用的物料不会报警**,阈值本身保留,随时可以重新启用。 + +| 是否入过库 | 现有库存 | 阈值 | 启用 | 状态 | +|------------|----------|------|------|----------| +| 否 | 0 | 0 | — | 不在列表中 | +| 是 | 0 | 0 | 默认 | 正常 | +| 是 | 5 | 0 | 默认 | 正常 | +| 是 | 0 | 10 | 是 | 库存不足 | +| 是 | 10 | 10 | 是 | 库存不足 | +| 是 | 15 | 10 | 是 | 正常 | +| 是 | 0 | 10 | 否 | 正常 | ### GET /stock -List all stock information with material details. +分页查询库存,只包含入过库且未作废的物料。 + +**查询参数:** + +| 参数 | 类型 | 必填 | 说明 | +|--------|---------|------|----------------------------| +| search | string | 否 | 按物料编码或名称模糊搜索 | +| limit | integer | 否 | 每页条数,默认 20,上限 100 | +| offset | integer | 否 | 偏移量,默认 0 | + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): -**Response Body:** ```json { "code": 0, - "data": [ - { - "material_id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "total_on_hand": 500, - "warning_stock": 100 - } - ], - "message": "success" + "message": "success", + "data": { + "items": [ + { + "material_id": 1, + "material_code": "A01-0001", + "name": "内六角螺栓", + "spec": "M8x30", + "unit": "个", + "major_category": "A01", + "minor_category": "A01-01", + "total_on_hand": "500", + "warning_stock": "100", + "warning_enabled": true, + "stock_status": "normal" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success +| 字段 | 类型 | 说明 | +|-----------------|---------|---------------------------------------------------| +| major_category | string | 大类分类编码 | +| minor_category | string | 小类分类编码 | +| total_on_hand | string | 现有库存合计,以字符串返回以免精度丢失 | +| warning_stock | string | 预警阈值,未设置时为 `"0"` | +| warning_enabled | boolean | 阈值是否启用 | +| stock_status | string | `low`(库存不足)或 `normal`(正常) | + +**状态码:** `200 OK`、`401 Unauthorized` --- ### GET /stock/:materialId -Get stock information for a single material. +查询单个物料的库存。与 `GET /stock` 不同,**这个接口对任何物料都会给出答案**,包括从未入库的物料(返回 `total_on_hand` 为 0)。物料申请和出库表单依赖这个特性逐个查询物料。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-------------|---------|----------|-----------------| -| materialId | integer | Yes | Material ID | +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------------|---------|------|----------| +| materialId | integer | 是 | 物料 ID | + +**响应:** -**Response Body:** ```json { "code": 0, + "message": "success", "data": { "material_id": 1, - "material_code": "M001", - "name": "Steel Plate", - "spec": "10mm x 2000mm x 6000mm", - "category": "Raw Material", - "unit": "pcs", - "total_on_hand": 500, - "warning_stock": 100 - }, - "message": "success" + "material_code": "A01-0001", + "name": "内六角螺栓", + "spec": "M8x30", + "unit": "个", + "major_category": "A01", + "minor_category": "A01-01", + "total_on_hand": "500", + "warning_stock": "100", + "warning_enabled": true, + "stock_status": "normal" + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Material not found +**状态码:** + +- `200 OK` — 成功 +- `400 Bad Request` — 物料 ID 非法 +- `404 Not Found` — 物料不存在 --- ### GET /stock/warning -List materials where current stock is below warning stock threshold. +分页查询需要关注的物料:配置了启用状态且大于 0 的阈值,并且现有库存已触达该阈值。 + +**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): -**Response Body:** ```json { "code": 0, - "data": [ - { - "material_id": 2, - "material_code": "M002", - "name": "Aluminum Rod", - "spec": "20mm x 3000mm", - "category": "Raw Material", - "unit": "pcs", - "total_on_hand": 30, - "warning_stock": 100 - } - ], - "message": "success" + "message": "success", + "data": { + "items": [ + { + "material_id": 2, + "material_code": "A02-0003", + "name": "铝棒", + "spec": "20mm x 3000mm", + "unit": "根", + "major_category": "A02", + "minor_category": "A02-01", + "total_on_hand": "30", + "warning_stock": "100", + "warning_enabled": true, + "stock_status": "low" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } } ``` -**HTTP Status Codes:** -- `200 OK` - Success +**状态码:** `200 OK`、`401 Unauthorized` + +--- + +### PUT /stock/:materialId/warning + +设置、调整或停用某个物料的预警阈值。 + +**路径参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------------|---------|------|----------| +| materialId | integer | 是 | 物料 ID | + +**请求体:** + +```json +{ + "warning_stock": 100, + "enabled": true +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|---------------|---------|------|------------------------------------------------------------| +| warning_stock | number | 是 | 阈值,必须大于等于 0。只有大于 0 的阈值才能触发预警 | +| enabled | boolean | 否 | 默认 `true`;传 `false` 停用该物料的预警,阈值本身保留 | + +**响应:** + +```json +{ + "code": 0, + "message": "Stock warning updated successfully", + "data": null +} +``` + +**状态码:** + +- `200 OK` — 成功 +- `400 Bad Request` — 物料 ID 非法,或 `warning_stock` 为负数 + +--- + +### GET /stock/logs + +分页查询全部库存流水,按时间倒序。 + +**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 + +**响应**(`data` 为分页对象,此处只展示 `items` 内元素): + +```json +{ + "code": 0, + "message": "success", + "data": { + "items": [ + { + "id": 2, + "material_id": 1, + "change_type": "outbound", + "change_quantity": "-100", + "before_quantity": "500", + "after_quantity": "400", + "reference_id": 1, + "reference_type": "outbound", + "created_at": "2026-01-01T10:00:00Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1, + "has_more": false + } +} +``` + +| 字段 | 类型 | 说明 | +|-----------------|--------|------------------------------------------------| +| change_type | string | `inbound`(入库)或 `outbound`(出库) | +| change_quantity | string | **带符号**的变动数量,出库为负 | +| before_quantity | string | 变动前库存 | +| after_quantity | string | 变动后库存 | +| reference_type | string | 来源单据类型,如 `inbound`、`outbound` | +| reference_id | integer| 来源单据 ID | + +**状态码:** `200 OK`、`401 Unauthorized` --- ### GET /stock/logs/:materialId -Get stock change logs for a specific material. +分页查询某个物料的库存流水。 -**Path Parameters:** -| Parameter | Type | Required | Description | -|-------------|---------|----------|-----------------| -| materialId | integer | Yes | Material ID | +**路径参数:** -**Response Body:** -```json -{ - "code": 0, - "data": [ - { - "id": 1, - "material_id": 1, - "change_type": "inbound", - "quantity": 500, - "balance": 500, - "reference_id": 1, - "reference_type": "inbound", - "created_at": "2026-01-01T08:00:00Z" - }, - { - "id": 2, - "material_id": 1, - "change_type": "outbound", - "quantity": 100, - "balance": 400, - "reference_id": 1, - "reference_type": "outbound", - "created_at": "2026-01-01T10:00:00Z" - } - ], - "message": "success" -} -``` +| 参数 | 类型 | 必填 | 说明 | +|------------|---------|------|----------| +| materialId | integer | 是 | 物料 ID | -**HTTP Status Codes:** -- `200 OK` - Success -- `404 Not Found` - Material not found +**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。 + +**响应**:结构与 `GET /stock/logs` 相同。 + +> **注意**:本接口的 `change_quantity` 是**不带符号的正数**(方向由 `change_type` 区分),与 `GET /stock/logs` 的带符号约定不一致。这是当前实现的实际行为,使用前请留意。 + +**状态码:** + +- `200 OK` — 成功 +- `404 Not Found` — 物料不存在 --- -## Common Response Structure +## 状态码汇总 -All API responses follow this structure: - -```json -{ - "code": 0, - "data": {}, - "message": "success" -} -``` - -| Field | Type | Description | -|---------|---------|--------------------------------------------------------| -| code | integer | Status code (0 = success, non-zero = error) | -| data | object | Response data (null for delete operations) | -| message | string | Human-readable message | - -## HTTP Status Code Summary - -| Status Code | Description | -|-------------|--------------------------------------------------| -| 200 OK | Request succeeded | -| 201 Created | Resource created successfully | -| 400 Bad Request | Invalid request body or parameters | -| 404 Not Found | Resource not found | -| 500 Internal Server Error | Server-side error | +| 状态码 | 说明 | +|--------|--------------------------------------------------| +| 200 | 请求成功 | +| 201 | 资源创建成功 | +| 400 | 请求体或参数非法、物料不存在、库存不足 | +| 401 | 未登录或令牌失效 | +| 403 | 已登录但权限不足(如非管理员访问管理员接口) | +| 404 | 资源不存在 | +| 409 | 状态冲突,目前仅用于「物料仍有库存时作废」 | +| 500 | 服务端错误 | diff --git a/frontend/src/api/stock.js b/frontend/src/api/stock.js index 90d09d7e..6106f860 100644 --- a/frontend/src/api/stock.js +++ b/frontend/src/api/stock.js @@ -19,7 +19,7 @@ export const stockApi = { getInventoryBalanceDetails(materialId) { return request.get(`/stock/${materialId}/details`) }, - updateWarning(materialId, warningStock) { - return request.put(`/stock/${materialId}/warning`, { warning_stock: warningStock }) + updateWarning(materialId, warningStock, enabled = true) { + return request.put(`/stock/${materialId}/warning`, { warning_stock: warningStock, enabled }) } } diff --git a/frontend/src/stores/stock.js b/frontend/src/stores/stock.js index 7ac8c5c9..64063dd1 100644 --- a/frontend/src/stores/stock.js +++ b/frontend/src/stores/stock.js @@ -10,7 +10,8 @@ export const useStockStore = defineStore('stock', () => { const stockPage = 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 }) - // Cache for per-material stock lookup (materialId -> total_on_hand) + // 单物料库存缓存(materialId -> 库存对象)。出库和申请只要 total_on_hand, + // 入库表单还要读预警阈值,共用同一份缓存避免重复请求。 const stockCache = ref({}) 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) { return stockCache.value[materialId] } try { const res = await stockApi.getById(materialId) - const stock = res.data?.total_on_hand ?? 0 - stockCache.value[materialId] = stock - return stock + const detail = res.data ?? null + stockCache.value[materialId] = detail + return detail } catch { - stockCache.value[materialId] = 0 - return 0 + stockCache.value[materialId] = null + 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) { loading.value = true try { @@ -84,8 +92,10 @@ export const useStockStore = defineStore('stock', () => { } } - async function updateWarningStock(materialId, warningStock) { - await stockApi.updateWarning(materialId, warningStock) + async function updateWarningStock(materialId, warningStock, enabled = true) { + await stockApi.updateWarning(materialId, warningStock, enabled) + // 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。 + delete stockCache.value[materialId] await Promise.all([ fetchStock(stockPage.value.currentPage), fetchStockWarning(warningPage.value.currentPage) @@ -95,7 +105,8 @@ export const useStockStore = defineStore('stock', () => { return { stockList, warningList, logs, loading, stockPage, warningPage, logsPage, - fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockWarning, fetchStockLogs, + fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockDetailByMaterialId, + fetchStockWarning, fetchStockLogs, updateWarningStock } }) diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index b6d4ef84..04dddb4f 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -56,23 +56,42 @@
- - - - - 删除 +
+ + + + +
+
+ + 预警值 + + + 删除 +
添加物料
@@ -112,6 +131,7 @@ import { ref, reactive, onMounted } from 'vue' import { useInboundStore } from '../stores/inbound' import { useMaterialsStore } from '../stores/materials' +import { useStockStore } from '../stores/stock' import { useAuthStore } from '../stores/auth' import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' @@ -120,6 +140,7 @@ import { ElMessage } from 'element-plus' const inboundStore = useInboundStore() const materialsStore = useMaterialsStore() +const stockStore = useStockStore() const authStore = useAuthStore() const dialogVisible = ref(false) @@ -131,11 +152,24 @@ const editingId = ref(null) const deleteId = 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({ handler: '', supplier: '', remark: '', - items: [{ material_id: null, materialSearchText: '', quantity: 1 }] + items: [createItem()] }) onMounted(() => { @@ -150,7 +184,7 @@ function handlePageChange(page) { function showAddModal() { editingId.value = null 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 } @@ -160,10 +194,18 @@ function showEditModal(row) { const items = row.items && row.items.length > 0 ? row.items.map(item => { 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 }) + // 预填的是物料"当前"的阈值,不是这张单当年的值;用户不动它就不会被提交回去。 + form.items.forEach(item => prefillWarningStock(item)) dialogVisible.value = true } @@ -181,7 +223,20 @@ async function showDetail(row) { } 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) { @@ -203,15 +258,26 @@ function getFilteredMaterials(item) { function onMaterialChange(item, id) { const idx = form.items.indexOf(item) - if (idx !== -1) { - form.items.splice(idx, 1, { ...item }) - } + if (idx === -1) return + 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() { saveLoading.value = true 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) { ElMessage.warning('请至少添加一个物料') return @@ -256,9 +322,29 @@ function formatDate(dateStr) { margin-bottom: 20px; } .item-row { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} +.item-row__main { display: flex; 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 { margin-top: 16px; diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue index 152d627d..d400f197 100644 --- a/frontend/src/views/Stock.vue +++ b/frontend/src/views/Stock.vue @@ -54,7 +54,8 @@ @@ -158,8 +159,12 @@ {{ warningMaterial?.material_code }} - {{ warningMaterial?.name }} + + + 关闭后该物料不再出现在库存预警列表,阈值会保留 + - + + + + + + +

{{ descriptionDetailSubject }}

+

{{ descriptionDetailText }}

+ +
@@ -105,7 +121,7 @@ const rootCategories = computed(() => categories.value.filter(item => !item.pare const form = reactive(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) { @@ -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) @@ -165,4 +194,6 @@ onMounted(loadCurrent) .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .page-header h2 { margin: 0 0 6px; } .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; } diff --git a/internal/handler/api.go b/internal/handler/api.go index 3ea5113a..d93ae5d9 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -83,6 +83,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { materials.POST("", h.CreateMaterial) materials.PUT("/:id", h.UpdateMaterial) materials.POST("/:id/obsolete", h.ObsoleteMaterial) + materials.POST("/:id/restore", h.RestoreMaterial) } inbound := api.Group("/inbound") @@ -115,6 +116,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { stock.GET("/logs", h.GetAllStockLogs) stock.GET("/logs/:materialId", h.GetStockLogs) stock.PUT("/:materialId/warning", h.UpdateWarningStock) + stock.GET("/:materialId/details", h.GetInventoryBalanceDetails) stock.GET("/:materialId", h.GetStock) } @@ -367,12 +369,37 @@ func (h *Handler) ObsoleteMaterial(c *gin.Context) { response.Error(c, 409, err.Error()) return } + if strings.Contains(err.Error(), "物料不存在") { + response.Error(c, 404, err.Error()) + return + } response.InternalError(c, err.Error()) return } 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 func (h *Handler) ListInbound(c *gin.Context) { @@ -694,6 +721,35 @@ func (h *Handler) GetStock(c *gin.Context) { 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) { limit := 20 offset := 0 @@ -730,6 +786,9 @@ func (h *Handler) UpdateWarningStock(c *gin.Context) { } var req struct { WarningStock decimal.Decimal `json:"warning_stock"` + // Enabled 用指针接收:不传表示沿用旧行为(启用),只有显式传 false 才是关闭 + // 该物料的预警。 + Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { 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") 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()) return } diff --git a/internal/handler/routes_test.go b/internal/handler/routes_test.go index 67d0f8a5..cbabe414 100644 --- a/internal/handler/routes_test.go +++ b/internal/handler/routes_test.go @@ -23,7 +23,8 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) { "PUT /api/roles/:id", "GET /api/permissions", "GET /api/partners", "POST /api/partners", "PUT /api/partners/:id", "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", } for _, route := range want { diff --git a/internal/model/master_data.go b/internal/model/master_data.go index 105e5866..5cb1d582 100644 --- a/internal/model/master_data.go +++ b/internal/model/master_data.go @@ -5,6 +5,7 @@ type MaterialCategory struct { OrganizationID int64 `json:"organization_id"` Code string `json:"code"` Name string `json:"name"` + Description string `json:"description"` ParentID *int64 `json:"parent_id,omitempty"` Status string `json:"status"` } diff --git a/internal/model/model.go b/internal/model/model.go index 45a0f983..536bfaaf 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -110,17 +110,26 @@ type StockLog struct { // Stock represents stock information with material details type Stock struct { - MaterialID int64 `json:"material_id"` - MaterialCode string `json:"material_code"` - Name string `json:"name"` - Spec string `json:"spec,omitempty"` - Unit string `json:"unit,omitempty"` - MajorCategory string `json:"major_category"` - MinorCategory string `json:"minor_category"` - TotalOnHand decimal.Decimal `json:"total_on_hand"` - WarningStock decimal.Decimal `json:"warning_stock"` + MaterialID int64 `json:"material_id"` + MaterialCode string `json:"material_code"` + Name string `json:"name"` + Spec string `json:"spec,omitempty"` + Unit string `json:"unit,omitempty"` + MajorCategory string `json:"major_category"` + MinorCategory string `json:"minor_category"` + TotalOnHand decimal.Decimal `json:"total_on_hand"` + 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 type InventoryBalanceDetail struct { ID int64 `json:"id"` @@ -175,30 +184,28 @@ type OutboundRecord struct { // CreateMaterialRequest represents the request to create a material type CreateMaterialRequest struct { - OrganizationID int64 `json:"organization_id"` - MaterialCode string `json:"material_code" binding:"required"` - Name string `json:"name" binding:"required"` - MaterialImage *string `json:"material_image"` - Spec string `json:"spec"` - ManufacturerModel *string `json:"manufacturer_model"` - CategoryID *int64 `json:"category_id" binding:"required,gt=0"` - BaseUnitID *int64 `json:"base_unit_id"` - Unit string `json:"unit"` - WarningStock decimal.Decimal `json:"warning_stock"` + OrganizationID int64 `json:"organization_id"` + MaterialCode string `json:"material_code" binding:"required"` + Name string `json:"name" binding:"required"` + MaterialImage *string `json:"material_image"` + Spec string `json:"spec"` + ManufacturerModel *string `json:"manufacturer_model"` + CategoryID *int64 `json:"category_id" binding:"required,gt=0"` + BaseUnitID *int64 `json:"base_unit_id"` + Unit string `json:"unit"` } // UpdateMaterialRequest represents the request to update a material type UpdateMaterialRequest struct { - MaterialCode string `json:"material_code"` - Name string `json:"name"` - MaterialImage *string `json:"material_image"` - Spec string `json:"spec"` - ManufacturerModel *string `json:"manufacturer_model"` - CategoryID *int64 `json:"category_id" binding:"required,gt=0"` - BaseUnitID *int64 `json:"base_unit_id"` - Unit string `json:"unit"` - Status string `json:"status"` - WarningStock decimal.Decimal `json:"warning_stock"` + MaterialCode string `json:"material_code"` + Name string `json:"name"` + MaterialImage *string `json:"material_image"` + Spec string `json:"spec"` + ManufacturerModel *string `json:"manufacturer_model"` + CategoryID *int64 `json:"category_id" binding:"required,gt=0"` + BaseUnitID *int64 `json:"base_unit_id"` + Unit string `json:"unit"` + Status string `json:"status"` } // CreateInboundRequest represents the request to create an inbound order @@ -208,6 +215,8 @@ type CreateInboundRequest struct { Items []struct { MaterialID int64 `json:"material_id" binding:"required"` Quantity int `json:"quantity" binding:"required,gt=0"` + // WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。 + WarningStock *decimal.Decimal `json:"warning_stock"` } `json:"items" binding:"required,min=1"` } @@ -228,6 +237,8 @@ type UpdateInboundRequest struct { Items []struct { MaterialID int64 `json:"material_id" binding:"required"` Quantity int `json:"quantity" binding:"required,gt=0"` + // WarningStock 可选,为 nil 表示本次不改动已存的预警阈值。 + WarningStock *decimal.Decimal `json:"warning_stock"` } `json:"items" binding:"required,min=1"` } diff --git a/internal/repository/master_data.go b/internal/repository/master_data.go index 645fb742..a393d02a 100644 --- a/internal/repository/master_data.go +++ b/internal/repository/master_data.go @@ -10,7 +10,7 @@ type MasterDataRepository struct{} func NewMasterDataRepository() *MasterDataRepository { return &MasterDataRepository{} } 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 { return nil, err } @@ -18,7 +18,7 @@ func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.Mat items := make([]model.MaterialCategory, 0) for rows.Next() { 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 } items = append(items, item) @@ -27,7 +27,7 @@ func (r *MasterDataRepository) ListCategories(organizationID int64) ([]model.Mat } 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 { return err } @@ -36,7 +36,7 @@ func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) erro } 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 } diff --git a/internal/repository/material.go b/internal/repository/material.go index 046323e2..978943ad 100644 --- a/internal/repository/material.go +++ b/internal/repository/material.go @@ -231,8 +231,17 @@ func (r *MaterialRepo) Obsolete(id int64) error { if nonZero > 0 { return fmt.Errorf("该物料仍有库存,无法作废") } - _, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id) - return err + result, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id) + 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 { diff --git a/internal/repository/material_test.go b/internal/repository/material_test.go index 7ca5757e..a71cbd0e 100644 --- a/internal/repository/material_test.go +++ b/internal/repository/material_test.go @@ -3,6 +3,7 @@ package repository import ( "encoding/json" "regexp" + "strings" "testing" "time" @@ -76,3 +77,72 @@ func TestMaterialRepoRecognizesOnlyActiveSubcategories(t *testing.T) { 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) + } +} diff --git a/internal/repository/stock.go b/internal/repository/stock.go index ee459bb2..485fa8a5 100644 --- a/internal/repository/stock.go +++ b/internal/repository/stock.go @@ -9,6 +9,30 @@ import ( "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{} func NewStockRepo() *StockRepo { @@ -16,7 +40,7 @@ func NewStockRepo() *StockRepo { } 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{} 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, - 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, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id - AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE + 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 pc ON pc.id = c.parent_id - WHERE 1=1` + WHERE 1=1` + stockedMaterialFilterSQL var args []interface{} if search != "" { @@ -61,7 +85,7 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock var stocks []model.Stock for rows.Next() { 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 { return nil, 0, err } @@ -70,20 +94,22 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock return stocks, total, nil } +// GetByMaterialID 返回单个物料的库存。这里刻意不套 stockedMaterialFilterSQL:物料申请和 +// 出库表单是按物料逐个查询的,无论有没有入过库都必须给出答案。 func (r *StockRepo) GetByMaterialID(materialID int64) (*model.Stock, error) { s := &model.Stock{} 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, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id - AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE + 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 pc ON pc.id = c.parent_id WHERE m.id = ? GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code` - err := database.DB.QueryRow(query, materialID).Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.Unit, &s.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 { return nil, err } @@ -96,9 +122,10 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id - AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE + AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id + WHERE 1=1` + stockedMaterialFilterSQL + ` GROUP BY m.id - HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0) + ` + stockWarningHavingSQL + ` ) warning_materials` var total int64 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, - 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, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id - AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE + 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 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 - HAVING COALESCE(SUM(ib.on_hand), 0) < COALESCE(MAX(swr.minimum_available), 0) + ` + stockWarningHavingSQL + ` ORDER BY m.id LIMIT ? OFFSET ?` rows, err := database.DB.Query(query, limit, offset) if err != nil { @@ -127,7 +155,7 @@ func (r *StockRepo) GetWarningStock(limit, offset int) ([]model.Stock, int64, er var stocks []model.Stock for rows.Next() { var s model.Stock - err := rows.Scan(&s.MaterialID, &s.MaterialCode, &s.Name, &s.Spec, &s.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 { return nil, 0, err } @@ -342,15 +370,18 @@ func (r *StockRepo) UpdateStockTx(tx *sql.Tx, materialID int64, delta decimal.De return nil } -// UpdateWarningStock stores the default warehouse threshold in the inventory domain. -func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal) error { - query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled) - SELECT o.id, w.id, ?, ?, TRUE - FROM organizations o +const defaultWarehouseExistsSQL = `SELECT COUNT(*) FROM organizations o JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT' - WHERE o.code = 'DEFAULT' - ON DUPLICATE KEY UPDATE minimum_available = VALUES(minimum_available), enabled = TRUE` - result, err := database.DB.Exec(query, materialID, minimumAvailable) + WHERE o.code = 'DEFAULT'` + +// 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 { return err } @@ -359,11 +390,30 @@ func (r *StockRepo) UpdateWarningStock(materialID int64, minimumAvailable decima return err } 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 } +// 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 { query := `DELETE FROM stock_logs WHERE related_order_type = ? AND related_order_id = ?` _, err := database.DB.Exec(query, orderType, orderID) diff --git a/internal/repository/stock_test.go b/internal/repository/stock_test.go index 47bde40f..c2c47d66 100644 --- a/internal/repository/stock_test.go +++ b/internal/repository/stock_test.go @@ -10,12 +10,12 @@ import ( ) 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, '') FROM materials m LEFT JOIN inventory_balances ib ON ib.material_id = m.id LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id - AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id AND swr.enabled = TRUE + 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 pc ON pc.id = c.parent_id WHERE m.id = ? @@ -33,8 +33,8 @@ func TestStockRepoGetByMaterialIDReadsInventoryTables(t *testing.T) { database.DB = db mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows( - sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "major_category", "minor_category"}). - AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", "B01", "CAT-SUB-027"), + 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", true, "B01", "CAT-SUB-027"), ) 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)) { 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" { t.Fatalf("unexpected stock categories: %+v", stock) } @@ -121,18 +124,134 @@ func TestStockRepoUpdateWarningStockWritesInventoryRule(t *testing.T) { }) database.DB = db - query := `INSERT INTO stock_warning_rules (organization_id, warehouse_id, material_id, minimum_available, enabled) - SELECT o.id, w.id, ?, ?, TRUE - FROM organizations o - JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT' - WHERE o.code = 'DEFAULT' - ON DUPLICATE KEY UPDATE minimum_available = VALUES(minimum_available), enabled = TRUE` - mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(13), "8.75").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)). + WithArgs(int64(13), "8.75", true).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) } if err := mock.ExpectationsWereMet(); err != nil { 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) + } +} diff --git a/internal/service/admin_master.go b/internal/service/admin_master.go index bbc8b06e..cf5fea7b 100644 --- a/internal/service/admin_master.go +++ b/internal/service/admin_master.go @@ -70,6 +70,7 @@ func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategor func (s *MasterDataService) SaveCategory(item *model.MaterialCategory) error { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) + item.Description = strings.TrimSpace(item.Description) if item.Code == "" || item.Name == "" { return errInvalidMasterData } diff --git a/internal/service/errors.go b/internal/service/errors.go index c5b314b6..b509bd05 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -3,3 +3,5 @@ package service import "errors" var ErrMaterialNotFound = errors.New("material not found") + +var ErrInvalidWarningStock = errors.New("warning stock must be greater than or equal to 0") diff --git a/internal/service/inbound.go b/internal/service/inbound.go index d45bcb3d..3b8aa78f 100644 --- a/internal/service/inbound.go +++ b/internal/service/inbound.go @@ -39,6 +39,9 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound if !exists { return nil, ErrMaterialNotFound } + if item.WarningStock != nil && item.WarningStock.IsNegative() { + return nil, ErrInvalidWarningStock + } order.Items[i] = model.InboundItem{ MaterialID: item.MaterialID, Quantity: item.Quantity, @@ -65,7 +68,9 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound // Update stock and create logs within transaction for i := range order.Items { 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 { return nil, err } @@ -77,6 +82,15 @@ func (s *InboundService) Create(req *model.CreateInboundRequest) (*model.Inbound 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{ MaterialID: item.MaterialID, Type: "in", @@ -164,6 +178,13 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod return nil, err } + // 阈值校验放在事务之外,避免为非法输入白开一个事务 + for _, item := range req.Items { + if item.WarningStock != nil && item.WarningStock.IsNegative() { + return nil, ErrInvalidWarningStock + } + } + // Start transaction tx, err := repository.BeginTx() if err != nil { @@ -210,6 +231,15 @@ func (s *InboundService) Update(id int64, req *model.UpdateInboundRequest) (*mod 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{ MaterialID: item.MaterialID, Type: "in", diff --git a/internal/service/stock.go b/internal/service/stock.go index b1793965..38c57cfb 100644 --- a/internal/service/stock.go +++ b/internal/service/stock.go @@ -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) { - 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) { - 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) { - 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 { - return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable) +func (s *StockService) UpdateWarningStock(materialID int64, minimumAvailable decimal.Decimal, enabled bool) error { + return s.stockRepo.UpdateWarningStock(materialID, minimumAvailable, enabled) } func (s *StockService) GetLogsByMaterialID(materialID int64, limit, offset int) ([]model.StockLogResponse, int64, error) { diff --git a/internal/service/stock_test.go b/internal/service/stock_test.go new file mode 100644 index 00000000..a99bac34 --- /dev/null +++ b/internal/service/stock_test.go @@ -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) + } + }) + } +} diff --git a/scripts/init.sql b/scripts/init.sql index 5e5c3952..2e4b5920 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -1,4 +1,7 @@ -- 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; 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 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_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 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; @@ -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 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_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','其他'); -INSERT INTO material_categories (organization_id,code,name,parent_id,status) -SELECT organization_id,CONCAT(code,'-01'),CONCAT(name,'·通用型'),id,status FROM material_categories WHERE parent_id IS NULL; -INSERT INTO material_categories (organization_id,code,name,parent_id,status) -SELECT organization_id,CONCAT(code,'-02'),CONCAT(name,'·专用型'),id,status FROM material_categories WHERE parent_id IS NULL; +-- Category tree: 6 majors (code A-F) + 28 subcategories (code A01-F05). +INSERT INTO material_categories (organization_id,code,name,description) VALUES +(1,'A','机械结构类','遵循通用分级规则:零加工外购标准件 → 零加工外购成品件 → 外购原料简易加工 → 深度定制加工件 → 本类兜底。纯机械传动件纳入本类管理。'), +(1,'B','电气元器件类','遵循通用分级规则:零加工外购标准成品 → 外购原料简易加工 → 单项工艺定制加工 → 多工艺集成组装件 → 本类兜底。带电气接口、电控功能的电机、电动部件统一归入本类;外购卷材/长条电气件,现场裁剪使用的纳入简易加工子类。'), +(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 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 --- after MySQL imports this file so they remain correct regardless of client codepage. -UPDATE organizations SET name=CONVERT(BINARY CONVERT(name USING latin1) USING utf8mb4); -UPDATE roles SET name=CONVERT(BINARY CONVERT(name USING latin1) USING 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); +-- Seed encoding is handled by SET NAMES utf8mb4 at the top of this file. The +-- previous latin1/BINARY round-trip repair is gone: it only produced correct +-- text when the client was latin1, and it corrupts (or destroys non-latin1 +-- characters such as "→") when the client is already utf8mb4. diff --git a/scripts/update_material_categories.sql b/scripts/update_material_categories.sql new file mode 100644 index 00000000..cac61fd0 --- /dev/null +++ b/scripts/update_material_categories.sql @@ -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; diff --git a/server.exe b/server.exe index 751e25a8..da7dcd5f 100644 Binary files a/server.exe and b/server.exe differ