feat(bid): 完成物料管理模块全功能开发

1. 新增物料详情页路由、菜单与接口,支持查看物料报价与信息
2. 重构物料列表页面,新增品牌筛选、表格样式优化与详情跳转
3. 扩展物料实体与数据库字段,新增材质、用途、性能参数等字段
4. 新增供应商/甲方报价查询、批量对比、同名称物料匹配接口
5. 新增物料详情组件,包含基础信息、供应商报价、甲方报价标签页
6. 修复比价路由跳转路径错误,调整数据库密码配置
7. 新增物料相关SQL脚本与初始化数据
This commit is contained in:
2026-05-29 08:58:58 +08:00
parent c718ec4076
commit e521b0dfeb
26 changed files with 4871 additions and 41 deletions

View File

@@ -230,6 +230,7 @@ VALUES
(2003,'报价请求',2000,3,'rfq','bid/rfq/index',NULL,1,0,'C','0','0','bid:rfq:list','form','admin',NOW(),'','',''),
(2004,'供应商报价',2000,4,'quotation','bid/quotation/index',NULL,1,0,'C','0','0','bid:quotation:list','money','admin',NOW(),'','',''),
(2005,'智慧比价',2000,5,'comparison','bid/comparison/index',NULL,1,0,'C','0','0','bid:comparison:list','chart','admin',NOW(),'','',''),
(2051,'比价详情',2005,1,'detail','bid/comparison/detail',NULL,1,0,'C','1','0','bid:comparison:detail','#','admin',NOW(),'','',''),
(2006,'采购单',2000,6,'purchaseorder','bid/purchaseorder/index',NULL,1,0,'C','0','0','bid:purchaseorder:list','shopping','admin',NOW(),'','',''),
(2007,'供应商评价',2000,7,'evaluation','bid/evaluation/index',NULL,1,0,'C','0','0','bid:evaluation:list','star','admin',NOW(),'','',''),
(2008,'订单异议',2000,8,'objection','bid/objection/index',NULL,1,0,'C','0','0','bid:objection:list','warning','admin',NOW(),'','',''),

View File

@@ -0,0 +1,497 @@
# 福安德智慧报价平台 - 数据库业务表结构分析报告
> 文档版本: v1.0
> 生成日期: 2025-05-27
> 关联文档: [福安德智慧报价平台-完整开发方案.md](./福安德智慧报价平台-完整开发方案.md)
---
## 一、概述
本报告对福安德智慧报价平台的数据库业务表结构进行全面分析,评估现有表结构是否满足开发方案中的业务需求,并指出缺失字段和建议优化项。
### 1.1 业务模块划分
根据开发方案,系统包含以下核心业务模块:
1. **物料信息管理** - 基础数据层
2. **供应商管理** - 供货商基础、历史报价、纠纷
3. **报价请求(RFQ)** - 询价单管理
4. **供应商报价** - 子报价单填写
5. **智慧比价** - 报价对比分析
6. **采购单管理** - 订单执行
7. **供应商评价** - 绩效评估
8. **订单异议** - 纠纷处理
9. **交易记录** - 财务流水
---
## 二、业务表结构详细分析
### 2.1 物料分类表 (biz_material_category)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| category_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| category_name | VARCHAR(100) | NO | - | 分类名称 | ✅ |
| parent_id | BIGINT | YES | 0 | 父分类ID | ✅ |
| ancestors | VARCHAR(500) | YES | '' | 祖级列表 | ✅ |
| sort | INT | YES | 0 | 排序 | ✅ |
| status | CHAR(1) | YES | '0' | 状态 | ⚠️ 无注释 |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 80% |
| 缺失字段 | category_code(分类编码)、icon(图标)、level(层级) |
| 建议优化 | 补充字段注释 |
---
### 2.2 物料表 (biz_material)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| material_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| category_id | BIGINT | YES | 0 | 分类ID | ✅ |
| material_code | VARCHAR(50) | NO | - | 物料编码 | ✅ |
| material_name | VARCHAR(200) | NO | - | 物料名称 | ✅ |
| spec | VARCHAR(500) | YES | '' | 规格型号 | ✅ |
| unit | VARCHAR(50) | YES | '' | 单位 | ✅ |
| brand | VARCHAR(100) | YES | '' | **厂家/品牌** | ✅ 已修改注释 |
| description | TEXT | YES | NULL | 描述 | ✅ |
| status | CHAR(1) | YES | '0' | 状态 | ⚠️ 无注释 |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| update_by | VARCHAR(64) | YES | '' | 更新者 | ⚠️ 无注释 |
| update_time | DATETIME | YES | NULL | 更新时间 | ⚠️ 无注释 |
| remark | VARCHAR(500) | YES | NULL | 备注 | ✅ |
#### 二期扩展字段 (2026-05-27)
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| performance_params | TEXT | YES | NULL | 性能参数(JSON) | ✅ 新增 |
| material | VARCHAR(100) | YES | '' | 材质 | ✅ 新增 |
| purpose | VARCHAR(500) | YES | '' | 用途 | ✅ 新增 |
| image_url | VARCHAR(500) | YES | '' | 物料图片URL | ✅ 新增 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 95% |
| 已满足需求 | 物料ID、厂家/品牌、规格型号、性能参数、材质、用途、备注 |
| 建议优化 | 补充status、create_by等字段注释 |
---
### 2.3 供应商表 (biz_supplier)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| supplier_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| supplier_name | VARCHAR(200) | NO | - | 供应商名称 | ✅ |
| contact | VARCHAR(50) | YES | '' | 联系人 | ⚠️ 无注释 |
| phone | VARCHAR(20) | YES | '' | 联系电话 | ⚠️ 无注释 |
| email | VARCHAR(100) | YES | '' | 邮箱 | ⚠️ 无注释 |
| address | VARCHAR(500) | YES | '' | 地址 | ⚠️ 无注释 |
| user_id | BIGINT | YES | NULL | 关联用户ID | ❌ 无注释,用途不明 |
| status | CHAR(1) | YES | '0' | 状态 | ⚠️ 无注释 |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| update_by | VARCHAR(64) | YES | '' | 更新者 | ⚠️ 无注释 |
| update_time | DATETIME | YES | NULL | 更新时间 | ⚠️ 无注释 |
| remark | VARCHAR(500) | YES | NULL | 备注 | ✅ |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 60% |
| **缺失字段** | 供应商编码、统一社会信用代码、开户银行、银行账号、合作状态、资质证书 |
| **需求不匹配** | 缺少"历史报价"、"纠纷"相关字段(应在关联表中) |
| 建议优化 | 补充所有字段注释;考虑增加供应商分类字段 |
---
### 2.4 报价请求主表 (biz_rfq)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| rfq_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| rfq_no | VARCHAR(50) | NO | - | 询价单号 | ✅ |
| rfq_title | VARCHAR(200) | NO | - | 询价标题 | ✅ |
| deadline | DATETIME | YES | NULL | 截止日期 | ⚠️ 无注释 |
| delivery_addr | VARCHAR(500) | YES | '' | 交货地址 | ⚠️ 无注释 |
| status | VARCHAR(20) | YES | 'draft' | 状态 | ⚠️ 无注释 |
| remark | TEXT | YES | NULL | 备注 | ✅ |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| update_by | VARCHAR(64) | YES | '' | 更新者 | ⚠️ 无注释 |
| update_time | DATETIME | YES | NULL | 更新时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 70% |
| **缺失字段** | 询价类型、紧急程度、付款方式、税率、甲方ID、项目ID |
| **状态值不明确** | status字段应注释状态值: draft/published/closed/completed |
| 建议优化 | 补充字段注释;明确状态流转规则 |
---
### 2.5 报价请求明细表 (biz_rfq_item)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| item_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| rfq_id | BIGINT | NO | - | 询价单ID | ✅ |
| material_id | BIGINT | YES | 0 | 物料ID | ✅ |
| material_name | VARCHAR(200) | NO | - | 物料名称 | ✅ |
| spec | VARCHAR(500) | YES | '' | 规格 | ⚠️ 无注释 |
| unit | VARCHAR(50) | YES | '' | 单位 | ⚠️ 无注释 |
| quantity | DECIMAL(15,4) | NO | - | 数量 | ⚠️ 无注释 |
| expected_price | DECIMAL(15,4) | YES | NULL | 期望单价 | ⚠️ 无注释 |
| remark | VARCHAR(500) | YES | '' | 备注 | ✅ |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 85% |
| **缺失字段** | 技术要求、质量标准、品牌要求、交货期要求 |
| 建议优化 | 补充字段注释 |
---
### 2.6 询价供应商关联表 (biz_rfq_supplier)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| rfq_id | BIGINT | NO | - | 询价单ID | ✅ |
| supplier_id | BIGINT | NO | - | 供应商ID | ✅ |
| invited_time | DATETIME | YES | NULL | 邀请时间 | ⚠️ 无注释 |
| quoted_time | DATETIME | YES | NULL | 报价时间 | ⚠️ 无注释 |
| status | VARCHAR(20) | YES | 'pending' | 状态 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 75% |
| **缺失字段** | 邀请人、邀请方式、是否查看、查看时间 |
| **状态值不明确** | status字段应注释: pending/invited/quoted/rejected |
| 建议优化 | 补充字段注释;增加邀请记录追踪 |
---
### 2.7 供应商报价单表 (biz_quotation)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| quotation_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| rfq_id | BIGINT | NO | - | 询价单ID | ✅ |
| supplier_id | BIGINT | NO | - | 供应商ID | ✅ |
| quote_no | VARCHAR(50) | YES | '' | 报价单号 | ⚠️ 无注释 |
| valid_days | INT | YES | 30 | 有效期(天) | ⚠️ 无注释 |
| delivery_days | INT | YES | 0 | 交货周期(天) | ⚠️ 无注释 |
| total_amount | DECIMAL(15,4) | YES | 0 | 总金额 | ⚠️ 无注释 |
| currency | VARCHAR(10) | YES | 'CNY' | 币种 | ⚠️ 无注释 |
| status | VARCHAR(20) | YES | 'draft' | 状态 | ⚠️ 无注释 |
| note | TEXT | YES | NULL | 备注 | ⚠️ 无注释 |
| submit_time | DATETIME | YES | NULL | 提交时间 | ⚠️ 无注释 |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| update_time | DATETIME | YES | NULL | 更新时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 70% |
| **缺失字段** | 报价有效期截止日期、付款方式、运费承担方、税率、是否含税 |
| **状态值不明确** | status字段应注释: draft/submitted/accepted/rejected |
| 建议优化 | 补充所有字段注释 |
---
### 2.8 报价明细表 (biz_quotation_item)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| item_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| quotation_id | BIGINT | NO | - | 报价单ID | ✅ |
| rfq_item_id | BIGINT | NO | - | 询价明细ID | ✅ |
| material_name | VARCHAR(200) | YES | '' | 物料名称 | ⚠️ 无注释 |
| spec | VARCHAR(500) | YES | '' | 规格 | ⚠️ 无注释 |
| unit | VARCHAR(50) | YES | '' | 单位 | ⚠️ 无注释 |
| quantity | DECIMAL(15,4) | YES | 0 | 数量 | ⚠️ 无注释 |
| unit_price | DECIMAL(15,4) | NO | - | 单价 | ⚠️ 无注释 |
| total_price | DECIMAL(15,4) | YES | 0 | 总价 | ⚠️ 无注释 |
| delivery_days | INT | YES | 0 | 交货天数 | ⚠️ 无注释 |
| remark | VARCHAR(500) | YES | '' | 备注 | ✅ |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 80% |
| **缺失字段** | 品牌、产地、质保期、最小起订量、库存状态 |
| 建议优化 | 补充所有字段注释 |
---
### 2.9 采购单主表 (biz_purchase_order)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| po_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| po_no | VARCHAR(50) | NO | - | 采购单号 | ✅ |
| rfq_id | BIGINT | YES | NULL | 询价单ID | ✅ |
| supplier_id | BIGINT | NO | - | 供应商ID | ✅ |
| total_amount | DECIMAL(15,4) | YES | 0 | 总金额 | ⚠️ 无注释 |
| currency | VARCHAR(10) | YES | 'CNY' | 币种 | ⚠️ 无注释 |
| delivery_addr | VARCHAR(500) | YES | '' | 交货地址 | ⚠️ 无注释 |
| delivery_date | DATE | YES | NULL | 交货日期 | ⚠️ 无注释 |
| status | VARCHAR(20) | YES | 'draft' | 状态 | ⚠️ 无注释 |
| remark | TEXT | YES | NULL | 备注 | ✅ |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| update_by | VARCHAR(64) | YES | '' | 更新者 | ⚠️ 无注释 |
| update_time | DATETIME | YES | NULL | 更新时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 65% |
| **缺失字段** | 合同编号、付款方式、付款比例、运费承担、税率、发票类型、联系人、联系电话 |
| **状态值不明确** | status字段应注释: draft/confirmed/shipped/received/completed |
| 建议优化 | 补充所有字段注释;增加订单执行跟踪字段 |
---
### 2.10 采购单明细表 (biz_purchase_order_item)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| item_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| po_id | BIGINT | NO | - | 采购单ID | ✅ |
| material_id | BIGINT | YES | 0 | 物料ID | ✅ |
| material_name | VARCHAR(200) | NO | - | 物料名称 | ✅ |
| spec | VARCHAR(500) | YES | '' | 规格 | ⚠️ 无注释 |
| unit | VARCHAR(50) | YES | '' | 单位 | ⚠️ 无注释 |
| quantity | DECIMAL(15,4) | NO | - | 数量 | ⚠️ 无注释 |
| unit_price | DECIMAL(15,4) | NO | - | 单价 | ⚠️ 无注释 |
| total_price | DECIMAL(15,4) | YES | 0 | 总价 | ⚠️ 无注释 |
| remark | VARCHAR(500) | YES | '' | 备注 | ✅ |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 75% |
| **缺失字段** | 已到货数量、到货率、质检状态、入库状态 |
| 建议优化 | 补充字段注释;增加订单执行跟踪字段 |
---
### 2.11 供应商评价表 (biz_supplier_evaluation)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| eval_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| po_id | BIGINT | NO | - | 采购单ID | ✅ |
| supplier_id | BIGINT | NO | - | 供应商ID | ✅ |
| quality_score | INT | YES | 5 | 质量评分 | ⚠️ 无注释 |
| delivery_score | INT | YES | 5 | 交货评分 | ⚠️ 无注释 |
| service_score | INT | YES | 5 | 服务评分 | ⚠️ 无注释 |
| price_score | INT | YES | 5 | 价格评分 | ⚠️ 无注释 |
| total_score | DECIMAL(3,1) | YES | 5.0 | 综合评分 | ⚠️ 无注释 |
| comment | TEXT | YES | NULL | 评价内容 | ⚠️ 无注释 |
| evaluator | VARCHAR(64) | YES | '' | 评价人 | ⚠️ 无注释 |
| eval_time | DATETIME | YES | NULL | 评价时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 75% |
| **缺失字段** | 评价周期、是否匿名、评价状态(草稿/已提交) |
| **评分范围不明确** | 应注释评分范围: 1-10分或1-5分 |
| 建议优化 | 补充所有字段注释;明确评分规则 |
---
### 2.12 订单异议表 (biz_order_objection)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| objection_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| po_id | BIGINT | NO | - | 采购单ID | ✅ |
| supplier_id | BIGINT | NO | - | 供应商ID | ✅ |
| reason | TEXT | NO | - | 异议原因 | ⚠️ 无注释 |
| attachment | VARCHAR(500) | YES | '' | 附件 | ⚠️ 无注释 |
| status | VARCHAR(20) | YES | 'pending' | 状态 | ⚠️ 无注释 |
| resolution | TEXT | YES | NULL | 解决方案 | ⚠️ 无注释 |
| create_by | VARCHAR(64) | YES | '' | 创建者 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
| resolve_time | DATETIME | YES | NULL | 解决时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 70% |
| **缺失字段** | 异议类型(质量/交期/数量/其他)、责任方、赔偿金额、关闭人、关闭时间 |
| **状态值不明确** | status字段应注释: pending/processing/resolved/closed |
| 建议优化 | 补充所有字段注释;完善异议处理流程 |
---
### 2.13 交易记录表 (biz_transaction)
#### 表结构
| 字段名 | 类型 | 可空 | 默认值 | 备注 | 需求匹配度 |
|--------|------|------|--------|------|-----------|
| tx_id | BIGINT | NO | AUTO_INCREMENT | 主键 | ✅ |
| tenant_id | BIGINT | NO | 1 | 租户ID | ✅ |
| po_id | BIGINT | YES | NULL | 采购单ID | ✅ |
| supplier_id | BIGINT | YES | NULL | 供应商ID | ✅ |
| tx_type | VARCHAR(50) | YES | '' | 交易类型 | ⚠️ 无注释 |
| tx_no | VARCHAR(50) | YES | '' | 交易单号 | ⚠️ 无注释 |
| amount | DECIMAL(15,4) | YES | NULL | 金额 | ⚠️ 无注释 |
| currency | VARCHAR(10) | YES | 'CNY' | 币种 | ⚠️ 无注释 |
| description | TEXT | YES | NULL | 描述 | ⚠️ 无注释 |
| operator | VARCHAR(64) | YES | '' | 操作人 | ⚠️ 无注释 |
| tx_time | DATETIME | YES | NULL | 交易时间 | ⚠️ 无注释 |
| create_time | DATETIME | YES | NULL | 创建时间 | ⚠️ 无注释 |
#### 分析结论
| 评估项 | 结果 |
|--------|------|
| 需求满足度 | 60% |
| **缺失字段** | 交易对方账户、交易状态、凭证号、附件、审核人、审核时间 |
| **交易类型不明确** | tx_type应注释: payment/refund/deposit/withdrawal |
| 建议优化 | 补充所有字段注释;完善财务审核流程 |
---
## 三、总体评估
### 3.1 各表需求满足度汇总
| 表名 | 需求满足度 | 主要问题 |
|------|-----------|---------|
| biz_material_category | 80% | 缺少分类编码、图标字段 |
| biz_material | 95% | 字段注释不完整 |
| biz_supplier | 60% | 缺少供应商编码、资质信息 |
| biz_rfq | 70% | 缺少询价类型、付款方式 |
| biz_rfq_item | 85% | 缺少技术要求、质量标准 |
| biz_rfq_supplier | 75% | 缺少邀请记录追踪 |
| biz_quotation | 70% | 缺少付款方式、运费承担 |
| biz_quotation_item | 80% | 缺少品牌、产地、质保期 |
| biz_purchase_order | 65% | 缺少合同信息、付款信息 |
| biz_purchase_order_item | 75% | 缺少到货跟踪字段 |
| biz_supplier_evaluation | 75% | 评分规则不明确 |
| biz_order_objection | 70% | 缺少异议类型、赔偿信息 |
| biz_transaction | 60% | 缺少交易状态、审核流程 |
### 3.2 共性问题
1. **字段注释缺失严重** - 约70%的字段缺少注释
2. **状态值不明确** - 所有status字段未注释状态值枚举
3. **缺少关键业务字段** - 如付款方式、税率、发票类型等
4. **缺少审计字段** - 部分表缺少update_by字段
### 3.3 建议优化项
#### 高优先级 (P0)
- [ ] 补充所有字段注释
- [ ] 明确所有status字段的状态值枚举
- [ ] 为biz_supplier添加供应商编码字段
#### 中优先级 (P1)
- [ ] 为biz_purchase_order添加合同信息字段
- [ ] 为biz_transaction添加交易状态字段
- [ ] 为biz_order_objection添加异议类型字段
#### 低优先级 (P2)
- [ ] 为biz_material_category添加分类编码
- [ ] 为biz_quotation_item添加品牌、产地字段
- [ ] 考虑增加数据版本控制字段
---
## 四、附录
### 4.1 相关文件
- 业务表SQL: `sql/bid_tables.sql`
- 物料扩展SQL: `sql/20260527/01_material_extension.sql`
- 开发方案: `doc/福安德智慧报价平台-完整开发方案.md`
- 物料开发文档: `doc/智采系统-物料信息模块开发文档.md`
### 4.2 建议补充的通用字段
所有业务表建议补充以下字段:
```sql
-- 数据版本控制
version INT DEFAULT 1 COMMENT '数据版本号',
-- 扩展字段(预留)
ext_field1 VARCHAR(500) COMMENT '扩展字段1',
ext_field2 VARCHAR(500) COMMENT '扩展字段2',
-- 数据隔离
dept_id BIGINT COMMENT '所属部门ID',
-- 删除标记(物理删除场景)
delete_time DATETIME COMMENT '删除时间',
delete_by VARCHAR(64) COMMENT '删除人',
```
---
*报告生成完成,建议根据以上分析逐步优化数据库结构。*

View File

@@ -0,0 +1,868 @@
# 智采系统-物料信息模块开发文档
> 文档版本: v1.1
> 创建日期: 2025-05-27
> 关联文档: [福安德智慧报价平台-完整开发方案.md](./福安德智慧报价平台-完整开发方案.md)
---
## 一、需求概述
### 1.1 功能定位
智采系统-物料信息模块是整个报价平台的基础数据层,负责管理所有物料的基础信息、性能参数、历史报价记录。
### 1.2 核心功能
- **物料信息管理**: 挂载于物料类型下支持汇总和CRUD操作
- **物料详情展示**: 基础信息展示,支持动态更新
- **历史报价查询**:
- 供应商历史报价清单(单价、供应商)
- 我方给甲方的历史报价
### 1.3 字段需求
根据业务需求,物料信息需要以下字段:
| 需求字段 | 对应数据库字段 | 说明 |
|---------|--------------|------|
| 物料ID | material_id | 主键,自增 |
| **厂家/品牌** | **brand** | **改为"厂家/品牌"** |
| 规格型号 | spec | 规格型号(**保持不变** |
| 性能参数 | performance_params | JSON格式存储**新增** |
| 材质 | material | 材质(铜/铝合金/PVC等)**新增** |
| 用途 | purpose | 用途描述(**新增** |
| 备注 | remark | 备注(**保持不变** |
---
## 二、数据库设计
### 2.1 现有表结构(保持不变)
```sql
CREATE TABLE `biz_material` (
`material_id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '1',
`category_id` bigint DEFAULT '0',
`material_code` varchar(50) NOT NULL COMMENT '物料编码',
`material_name` varchar(200) NOT NULL COMMENT '物料名称',
`spec` varchar(500) DEFAULT '' COMMENT '规格型号',
`unit` varchar(50) DEFAULT '' COMMENT '单位',
`brand` varchar(100) DEFAULT '' COMMENT '品牌',
`description` text COMMENT '描述',
`status` char(1) DEFAULT '0',
`create_by` varchar(64) DEFAULT '',
`create_time` datetime DEFAULT NULL,
`update_by` varchar(64) DEFAULT '',
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL,
PRIMARY KEY (`material_id`)
) ENGINE=InnoDB COMMENT='物料';
```
### 2.2 扩展字段
执行SQL文件: [sql/20250527/01_material_alter.sql](../sql/20250527/01_material_alter.sql)
```sql
-- 新增字段
ALTER TABLE biz_material
ADD COLUMN IF NOT EXISTS performance_params TEXT COMMENT '性能参数(JSON)',
ADD COLUMN IF NOT EXISTS material VARCHAR(100) DEFAULT '' COMMENT '材质',
ADD COLUMN IF NOT EXISTS purpose VARCHAR(500) DEFAULT '' COMMENT '用途',
ADD COLUMN IF NOT EXISTS image_url VARCHAR(500) DEFAULT '' COMMENT '物料图片URL';
-- 修改brand字段为"厂家/品牌"
ALTER TABLE biz_material MODIFY COLUMN brand VARCHAR(100) COMMENT '厂家/品牌';
```
### 2.3 横向对比关系表
用于存储物料之间的对比关系(不同品牌、规格的同比信息):
```sql
CREATE TABLE biz_material_comparison (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 1 COMMENT '租户ID',
material_id BIGINT NOT NULL COMMENT '当前物料ID',
counterpart_id BIGINT NOT NULL COMMENT '对标物料ID',
comparison_type VARCHAR(50) DEFAULT 'same_spec' COMMENT '对比类型(same_spec:同规格, similar:相似规格)',
brand_level VARCHAR(50) COMMENT '品牌等级(国产一线/国产二线/进口一线/进口二线)',
sort_order INT DEFAULT 0 COMMENT '排序',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME COMMENT '更新时间',
remark VARCHAR(500) COMMENT '备注',
UNIQUE KEY uk_material_counterpart (material_id, counterpart_id),
KEY idx_material (material_id),
KEY idx_counterpart (counterpart_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物料横向对比关系';
```
**表设计说明**:
- 不采用"对比组"模式,而是直接建立物料之间的对比关系,更灵活
- 支持同规格对比和相似规格对比
- 可维护品牌等级,用于综合评分
- 通过唯一索引防止重复添加对标物料
### 2.3 字段对照说明
| 原字段 | 原含义 | 新含义 | 说明 |
|--------|--------|--------|------|
| spec | 规格型号 | **规格型号** | **保持不变** |
| **brand** | **品牌** | **厂家/品牌** | **修改注释** |
| remark | 备注 | **备注** | **保持不变** |
| performance_params | - | 性能参数 | 新增 |
| material | - | 材质 | 新增 |
| purpose | - | 用途 | 新增 |
| image_url | - | 物料图片URL | 新增 |
### 2.4 性能参数JSON规范
```json
// 变频器示例
{
"功率": "37kW",
"电压": "380V",
"电流": "72A",
"过载能力": "150%/60s",
"通讯接口": "RS485/PROFINET",
"防护等级": "IP20"
}
// 电缆示例
{
"芯数×截面": "3×70+1×35",
"额定电压": "0.6/1kV",
"外径": "42mm",
"载流量": "210A",
"护套材质": "PVC铠装"
}
// PLC示例
{
"CPU型号": "1515-2 PN",
"内存": "500KB",
"数字量输入": "1024",
"数字量输出": "1024",
"通讯接口": "PROFINET"
}
```
---
## 三、页面设计
### 3.1 物料信息列表页
#### 3.1.1 页面布局
```
┌─────────────────────────────────────────────────────────────┐
│ 智采系统-物料信息 [返回首页] │
├─────────────────────────────────────────────────────────────┤
│ 挂载于物料类型下面的物料信息在此进行汇总和CRUD │
├─────────────────────────────────────────────────────────────┤
│ 搜索栏: [物料名称] [物料编码] [厂家/品牌] [分类] [搜索] [重置]│
├─────────────────────────────────────────────────────────────┤
│ [新增] [删除] [导入] [导出] │
├─────────────────────────────────────────────────────────────┤
│ □ | 物料ID | 厂家/品牌 | 规格型号 | 性能参数 | 材质 | 用途 | 操作 │
│ ───────────────────────────────────────────────────────── │
│ □ | M001 | 汇川 | MD500T37G | 37kW... | 铜 | 通用 | [详情][修改][删除] │
│ □ | M002 | 西门子 | G120 | 37kW... | 铝 | 风机 | [详情][修改][删除] │
├─────────────────────────────────────────────────────────────┤
│ 分页: 1 2 3 ... 10 │
└─────────────────────────────────────────────────────────────┘
```
#### 3.1.2 表格字段
| 字段 | 宽度 | 对应字段 | 说明 |
|------|------|---------|------|
| 复选框 | 55px | - | 批量操作 |
| 物料ID | 100px | material_id | - |
| **厂家/品牌** | 120px | **brand** | **改为"厂家/品牌"** |
| **规格型号** | 150px | **spec** | **保持不变** |
| 性能参数 | 200px | performance_params | 截取显示 |
| 材质 | 80px | material | 新增 |
| 用途 | 150px | purpose | 新增 |
| 操作 | 180px | - | 详情/修改/删除 |
#### 3.1.3 操作按钮
- **详情**: 跳转到物料详情页
- **修改**: 打开编辑对话框
- **删除**: 删除物料(逻辑删除)
### 3.2 新增/编辑对话框
```
┌─────────────────────────────────────────┐
│ 新增物料 / 修改物料 │
├─────────────────────────────────────────┤
│ 物料编码* [________________] │
│ 物料名称* [________________] │
│ 所属分类 [下拉选择________] │
├─────────────────────────────────────────┤
│ 厂家/品牌* [________________] │ ← brand字段改为"厂家/品牌"
│ 规格型号* [________________] │ ← spec字段保持不变
│ 备注 [________________] │ ← remark字段保持不变
│ 材质 [________________] │ ← material字段新增
│ 用途 [________________] │ ← purpose字段新增
├─────────────────────────────────────────┤
│ 性能参数 (动态表单) │
│ ┌─────────┬─────────┬───────┐ [添加] │
│ │ 参数名 │ 参数值 │ 单位 │ │
│ │ [功率] │ [37] │ [kW] │ [删除] │
│ │ [电压] │ [380] │ [V] │ [删除] │
│ └─────────┴─────────┴───────┘ │
├─────────────────────────────────────────┤
│ 描述 [多行文本框____________] │ ← description字段
├─────────────────────────────────────────┤
│ [取消] [确定] │
└─────────────────────────────────────────┘
```
### 3.3 物料详情页
#### 3.3.1 整体布局(重新设计)
页面分为上下两部分:
- **上半部分**: 可编辑的基础信息(物料编码、厂家/品牌、规格型号等 + 性能参数 + 描述)
- **下半部分**: Tab切换供应商报价历史、甲方报价历史
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 明纬/西门子 电源模块 24VDC/10A输入85-264VAC效率≥90% [返回列表] │
├─────────────────────────────────────────────────────────────────────────────┤
│ 基础信息 [编辑] [保存] [取消] │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────┬────────────┬────────────┬────────────┬────────────┬────────┐│
│ │ 物料编码 │ M-EL-006 │ 厂家/品牌 │ 明纬/西门子│ 规格型号 │ 24VDC/10A...│
│ ├────────────┼────────────┼────────────┼────────────┼────────────┼────────┤│
│ │ 材质 │ - │ 用途 │ - │ 备注 │ - │
│ │ 单位 │ 台 │ 所属分类 │ 电源模块 │ │ │
│ └────────────┴────────────┴────────────┴────────────┴────────────┴────────┘│
├─────────────────────────────────────────────────────────────────────────────┤
│ 性能参数 [添加] │
│ ┌────────────────┬──────────────────────────────┬────────────────┬────────┐│
│ │ 参数名 │ 参数值 │ 单位 │ 操作 ││
│ ├────────────────┼──────────────────────────────┼────────────────┼────────┤│
│ │ 输出电压 │ 24VDC │ V │ [删除] ││
│ │ 输出电流 │ 10 │ A │ [删除] ││
│ │ ... │ ... │ ... │ ... ││
│ └────────────────┴──────────────────────────────┴────────────────┴────────┘│
├─────────────────────────────────────────────────────────────────────────────┤
│ 描述 │
│ ┌────────────────────────────────────────────────────────────────────────┐│
│ │ 明纬/西门子 电源模块 24VDC/10A输入85-264VAC效率≥90% ││
│ └────────────────────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────────────────────┤
│ [供应商报价历史] [甲方报价历史] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Tab 内容区域(报价历史表格) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**布局说明**:
- **顶部标题栏**: 显示物料名称和规格型号,右侧返回按钮
- **基础信息区域**: 紧凑的网格布局一行4个字段可直接编辑
- **性能参数区域**: 表格形式展示,支持增删改
- **描述区域**: 文本区域展示,支持编辑
- **Tab区域**: 仅保留供应商报价历史和甲方报价历史两个Tab
**组件实现**: [detail.vue](../ruoyi-ui/src/views/bid/material/detail.vue)
**核心功能**:
1. **基础信息网格布局**: 紧凑的flex布局一行显示多个字段
2. **实时编辑**: 点击"编辑"按钮,所有字段变为可编辑状态
3. **实时保存**: 点击"保存"调用API更新本地数据无需刷新页面
4. **性能参数表格**: 支持添加/删除参数
5. **描述区域**: 多行文本编辑
**数据结构**:
```javascript
// 表单数据
form: {
materialCode: 'M-EL-006',
materialName: '电源模块',
brand: '明纬/西门子',
spec: '24VDC/10A...',
unit: '台',
material: '',
purpose: '',
remark: '',
description: '明纬/西门子 电源模块...'
}
// 性能参数数据
perfParams: [
{ name: '输出电压', value: '24VDC', unit: 'V' },
{ name: '输出电流', value: '10', unit: 'A' }
]
```
**保存逻辑**:
```javascript
async handleSave() {
const saveData = {
materialId: this.materialId,
...this.form,
performanceParams: JSON.stringify(this.perfParams)
};
await updateMaterial(saveData);
this.$message.success("保存成功");
this.isEditing = false;
// 更新本地数据(无需刷新页面)
this.material = { ...this.material, ...saveData };
}
```
// ...
]
// 性能参数数据
perfParams: [
{ name: '输出电压', value: '24VDC', unit: 'V' },
{ name: '输出电流', value: '10', unit: 'A' },
// ...
]
// 描述数据
description: '明纬/西门子 电源模块 24VDC/10A...'
```
**保存逻辑**:
```javascript
async handleSave() {
const saveData = {
materialId: this.material.materialId,
materialCode: this.basicInfoData[0].value,
materialName: this.basicInfoData[1].value,
// ... 其他字段
performanceParams: JSON.stringify(this.perfParams),
description: this.description
};
await updateMaterial(saveData);
this.$message.success("保存成功");
this.isEditing = false;
// 更新本地数据(无需刷新页面)
this.$emit('update:material', { ...this.material, ...saveData });
}
```
#### 3.3.3 Tab 2: 供应商报价历史
供应商信息完整展示,调整列宽使内容在一行显示:
```
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 供应商报价历史 [导出Excel] │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ 报价日期 │ 供应商信息 │ 单价(元) │ 数量 │ 交期 │ 状态 │
│ ───────────────────────────────────────────────────────────────────────────────────── │
│ 2026-05-20 │ 华顺达电子科技有限公司 │ ¥6,200.00 │ 5 │ 7天 │ [已中标] │
│ │ 联系人: 张经理 13800138000│ │ │ │ │
│ ───────────────────────────────────────────────────────────────────────────────────── │
│ 2026-05-18 │ 博远精密机械有限公司 │ ¥6,500.00 │ 5 │ 10天 │ [未中标] │
│ │ 联系人: 李工 13900139000 │ │ │ │ │
│ ───────────────────────────────────────────────────────────────────────────────────── │
│ 2026-05-15 │ 瑞达工控设备有限公司 │ ¥6,800.00 │ 5 │ 5天 │ [未中标] │
│ │ 联系人: 王总 13700137000 │ │ │ │ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
```
**表格列宽配置**:
| 列名 | 宽度 | 说明 |
|------|------|------|
| 报价日期 | 100px | 日期格式YYYY-MM-DD |
| 供应商信息 | 280px | 供应商名称+联系人+电话,完整一行展示 |
| 单价 | 120px | 右对齐保留2位小数 |
| 数量 | 80px | 居中对齐 |
| 交期 | 80px | 居中对齐,带单位"天" |
| 状态 | 100px | 标签形式展示 |
**Vue组件实现**:
```vue
<template>
<div class="supplier-quote-tab">
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="warning" icon="el-icon-download" size="mini" @click="handleExport">导出Excel</el-button>
</el-col>
</el-row>
<el-table :data="quoteList" border v-loading="loading">
<el-table-column label="报价日期" prop="submitTime" width="100" align="center" />
<!-- 供应商信息合并展示 -->
<el-table-column label="供应商信息" min-width="280">
<template slot-scope="scope">
<div class="supplier-info">
<div class="supplier-name">{{ scope.row.supplierName }}</div>
<div class="supplier-contact">
<span>联系人: {{ scope.row.contact || '-' }}</span>
<span v-if="scope.row.phone">{{ scope.row.phone }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="单价(元)" prop="unitPrice" width="120" align="right">
<template slot-scope="scope">
<span style="color: #f56c6c; font-weight: bold;">¥{{ formatPrice(scope.row.unitPrice) }}</span>
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" width="80" align="center" />
<el-table-column label="交期" width="80" align="center">
<template slot-scope="scope">
<span>{{ scope.row.deliveryDays }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.isWinning === '1'" type="success" size="small">已中标</el-tag>
<el-tag v-else type="info" size="small">未中标</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.supplier-info {
line-height: 1.5;
}
.supplier-name {
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.supplier-contact {
font-size: 12px;
color: #909399;
}
.supplier-contact span {
margin-right: 8px;
}
</style>
```
**数据来源**: `biz_quotation_item``biz_quotation``biz_supplier`
#### 3.3.4 Tab 3: 甲方报价历史
```
┌─────────────────────────────────────────────────────────────┐
│ 我方给甲方的历史报价 [导出Excel] │
├─────────────────────────────────────────────────────────────┤
│ 报价日期 甲方名称 单价(元) 折扣率 成交价 状态 │
│ ───────────────────────────────────────────────────────── │
│ 2026-05-25 福安德科技 ¥8,000 95% ¥7,600 已接受│
│ 2026-05-22 福安德科技 ¥8,200 90% ¥7,380 已拒绝│
│ 2026-04-15 某某自动化 ¥8,500 100% ¥8,500 已接受│
└─────────────────────────────────────────────────────────────┘
```
**数据来源**: `biz_client_quote_item``biz_client_quote`
#### 3.3.5 同品类物料横向对比(基于供应商报价)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 同品类物料横向对比 3个物料 │
│ [选择对比物料(仅显示有报价的物料)... ] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │
│ │ PLC控制器 │ │ 变频器 │ │
│ │ CPU模块... / 西门子S7-1200 │ │ 三相380V... / ABB/西门子 │ │
│ │ 3家报价 │ │ 2家报价 │ │
│ ├─────────────────────────────┤ ├─────────────────────────────┤ │
│ │ 规格型号 │ CPU模块... │ │ 规格型号 │ 三相380V... │ │
│ │ 厂家/品牌│ 西门子S7-1200 │ │ 厂家/品牌│ ABB/西门子 │ │
│ │ 材质 │ — │ │ 材质 │ — │ │
│ │ 单位 │ 套 │ │ 单位 │ 台 │ │
│ ├─────────────────────────────┤ ├─────────────────────────────┤ │
│ │ ───── 供应商报价明细 ───── │ │ ───── 供应商报价明细 ───── │ │
│ ├─────────────────────────────┤ ├─────────────────────────────┤ │
│ │ 供应商 单价 交期 │ │ 供应商 单价 交期 │ │
│ │ 广州联盟... ¥3,020 30天 │ │ 华顺达... ¥6,200 7天 │ │
│ │ 深圳华顺... ¥3,180 25天 │ │ 博远... ¥6,500 10天 │ │
│ │ 上海瑞达... ¥3,450 20天 │ │ │ │
│ ├─────────────────────────────┤ ├─────────────────────────────┤ │
│ │ 最低¥3020 │ 最高¥3450 │ │ 最低¥6200 │ 最高¥6500 │ │
│ │ 均价¥3216 │ 价差¥430(+14.2%)│ │ 均价¥6350 │ 价差¥300(+4.8%) │ │
│ └─────────────────────────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
**功能说明**:
- **自动加载**: 进入页面自动加载同品类有报价的物料默认选中前3个
- **实时更新**: 选择框变更时即时加载对应报价明细
- **视觉区分**: 基础信息区域(浅蓝背景)与供应商报价区域(白色背景)通过分隔线明确区分
- **报价排序**: 按单价从低到高排序,最低价绿色高亮并标记"最低"标签
- **统计汇总**: 每个物料卡片底部展示最低价、最高价、均价、价差百分比
- **错误处理**: 加载失败显示错误提示和重试按钮
- **响应式**: 适配桌面端、平板和手机屏幕
**交互流程**:
1. 用户进入物料详情页 → 系统自动加载同品类物料列表
2. 默认选中前3个物料 → 自动加载这些物料的供应商报价明细
3. 用户可在选择框中增删物料 → 报价明细实时更新
4. 加载过程中显示loading状态 → 失败显示错误提示和重试按钮
**数据来源**:
- 物料信息: `biz_material`
- 供应商报价: `biz_quotation_item` + `biz_quotation` + `biz_supplier`
- 关联关系: `biz_rfq_item` (物料与报价明细的关联)
---
## 四、后端API设计
### 4.1 物料管理API
| 接口 | 方法 | 说明 | 权限 |
|------|------|------|------|
| `/bid/material/list` | GET | 物料列表 | bid:material:list |
| `/bid/material/{id}` | GET | 物料详情 | bid:material:query |
| `/bid/material` | POST | 新增物料 | bid:material:add |
| `/bid/material` | PUT | 修改物料 | bid:material:edit |
| `/bid/material/{id}` | DELETE | 删除物料 | bid:material:remove |
### 4.2 物料详情API
| 接口 | 方法 | 说明 | 权限 |
|------|------|------|------|
| `/bid/material/{id}/detail` | GET | 物料完整详情(含性能参数) | bid:material:detail |
| `/bid/material/{id}/supplier-quotes` | GET | 供应商历史报价清单 | bid:material:supplierQuotes |
| `/bid/material/{id}/client-quotes` | GET | 甲方历史报价清单 | bid:material:clientQuotes |
| `/bid/material/{id}/comparison` | GET | 横向对比信息 | bid:material:comparison |
### 4.3 同名称物料横向对比API基于物料名称精确匹配
| 接口 | 方法 | 说明 | 权限 | 备注 |
|------|------|------|------|------|
| `/bid/material/same-name/{materialName}` | GET | 按名称精确匹配查询物料 | bid:material:list | 返回名称相同但规格/品牌不同的物料(含无报价) |
| `/bid/material/quote-comparison` | POST | 获取物料报价对比明细 | bid:material:list | 根据物料ID列表返回各供应商报价 |
**GET /bid/material/same-name/{materialName}?excludeId=1** 响应示例:
```json
[
{
"materialId": 2,
"materialName": "PLC控制器",
"spec": "CPU模块数字量I/O 40点以太网接口",
"brand": "西门子S7-1200",
"material": "",
"purpose": "",
"unit": "套",
"categoryName": "控制器"
},
{
"materialId": 3,
"materialName": "PLC控制器",
"spec": "CPU模块数字量I/O 64点以太网接口",
"brand": "三菱FX5U",
"material": "",
"purpose": "",
"unit": "套",
"categoryName": "控制器"
}
]
```
**POST /bid/material/quote-comparison** 请求/响应示例:
```json
// 请求
[1, 2, 3]
// 响应
[
{
"material_id": 1,
"unit_price": 6200.00,
"total_price": 31000.00,
"delivery_days": 7,
"quotation_id": 101,
"quote_no": "Q20250520001",
"submit_time": "2026-05-20T10:30:00",
"quote_status": "2",
"supplier_id": 5,
"supplier_name": "华顺达电子科技有限公司",
"supplier_contact": "张经理",
"supplier_phone": "13800138000"
}
]
```
### 4.4 横向对比管理API可维护对标关系
| 接口 | 方法 | 说明 | 权限 |
|------|------|------|------|
| `/bid/material/comparison` | GET | 查询对比组列表 | bid:comparison:list |
| `/bid/material/comparison` | POST | 添加对标物料 | bid:comparison:add |
| `/bid/material/comparison/{id}` | PUT | 修改对标信息 | bid:comparison:edit |
| `/bid/material/comparison/{id}` | DELETE | 删除对标物料 | bid:comparison:remove |
| `/bid/material/comparison/recommend` | GET | 获取智能推荐 | bid:comparison:recommend |
---
## 五、前端组件设计
### 5.1 目录结构
```
src/views/bid/material/
├── index.vue # 物料列表页
├── detail.vue # 物料详情页(主框架)
└── components/
├── DetailHeader.vue # 顶部信息卡片
├── BasicInfoTab.vue # Tab1: 基础信息
├── SupplierQuoteTab.vue # Tab2: 供应商报价
├── ClientQuoteTab.vue # Tab3: 甲方报价
└── ComparisonTab.vue # Tab4: 横向对比
```
### 5.2 关键组件说明
#### 5.2.1 性能参数动态表单
```vue
<template>
<div class="performance-params-form">
<el-button type="primary" size="small" @click="addRow">+ 添加参数</el-button>
<el-table :data="params" border size="small" style="margin-top: 10px">
<el-table-column label="参数名" width="150">
<template slot-scope="scope">
<el-input v-model="scope.row.name" placeholder="如: 功率" />
</template>
</el-table-column>
<el-table-column label="参数值" width="150">
<template slot-scope="scope">
<el-input v-model="scope.row.value" placeholder="如: 37" />
</template>
</el-table-column>
<el-table-column label="单位" width="100">
<template slot-scope="scope">
<el-input v-model="scope.row.unit" placeholder="如: kW" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button type="text" style="color: #f56c6c" @click="removeRow(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
```
#### 5.2.2 横向对比组件 (ComparisonTab.vue)
```vue
<template>
<div class="comparison-tab">
<!-- 操作栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAdd">添加对标</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
</el-row>
<!-- 对比表格 -->
<el-table :data="comparisonData" border v-loading="loading">
<el-table-column type="selection" width="55" align="center" fixed="left" />
<el-table-column label="对比项" prop="attrName" width="120" fixed="left" />
<!-- 当前物料列 -->
<el-table-column :label="currentMaterial.brand + ' ' + currentMaterial.spec" align="center">
<template slot-scope="scope">
<span :class="getHighlightClass(scope.row, 'current')">{{ scope.row.currentValue }}</span>
</template>
</el-table-column>
<!-- 对标物料列动态生成 -->
<el-table-column
v-for="item in counterpartList"
:key="item.counterpartId"
:label="item.brand + ' ' + item.spec"
align="center">
<template slot-scope="scope">
<span :class="getHighlightClass(scope.row, item.counterpartId)">{{ scope.row[item.counterpartId] }}</span>
</template>
</el-table-column>
</el-table>
<!-- 智能推荐 -->
<el-card class="recommend-card" v-if="recommendation">
<div slot="header">
<span>💡 智能推荐</span>
</div>
<div>
<p><strong>推荐选型:</strong> {{ recommendation.brand }} {{ recommendation.spec }}</p>
<p><strong>推荐理由:</strong> {{ recommendation.reason }}</p>
<p><strong>价格优势:</strong> {{ recommendation.priceAdvantage }}</p>
</div>
</el-card>
<!-- 添加对标对话框 -->
<el-dialog title="添加对标物料" :visible.sync="open" width="600px">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="对标物料" prop="counterpartId">
<el-select v-model="form.counterpartId" filterable placeholder="请选择对标物料" style="width: 100%">
<el-option
v-for="m in materialOptions"
:key="m.materialId"
:label="m.brand + ' ' + m.spec + ' - ' + m.materialName"
:value="m.materialId" />
</el-select>
</el-form-item>
<el-form-item label="对比类型" prop="comparisonType">
<el-radio-group v-model="form.comparisonType">
<el-radio label="same_spec">同规格</el-radio>
<el-radio label="similar">相似规格</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="品牌等级" prop="brandLevel">
<el-select v-model="form.brandLevel" placeholder="请选择品牌等级" style="width: 100%">
<el-option label="国产一线" value="国产一线" />
<el-option label="国产二线" value="国产二线" />
<el-option label="进口一线" value="进口一线" />
<el-option label="进口二线" value="进口二线" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="open = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
props: ['materialId'],
data() {
return {
loading: false,
comparisonData: [],
counterpartList: [],
currentMaterial: {},
recommendation: null,
open: false,
multiple: true,
materialOptions: [],
form: {},
rules: {
counterpartId: [{ required: true, message: '请选择对标物料', trigger: 'change' }],
comparisonType: [{ required: true, message: '请选择对比类型', trigger: 'change' }],
brandLevel: [{ required: true, message: '请选择品牌等级', trigger: 'change' }]
}
}
},
created() {
this.getComparisonData()
},
methods: {
getComparisonData() {
// 获取横向对比数据
this.loading = true
// API调用: GET /bid/material/{id}/comparison
// 数据处理...
},
getHighlightClass(row, type) {
// 根据数值比较返回高亮样式
// 优于当前物料: green, 劣于当前物料: red
},
handleAdd() {
this.open = true
this.getMaterialOptions()
},
getMaterialOptions() {
// 获取可选物料列表(排除当前物料和已添加的对标物料)
},
submitForm() {
// 提交添加对标
// API调用: POST /bid/material/comparison
},
handleDelete() {
// 删除对标
// API调用: DELETE /bid/material/comparison/{id}
}
}
}
</script>
<style scoped>
.recommend-card {
margin-top: 20px;
background: #f0f9ff;
border-left: 4px solid #1890ff;
}
.highlight-green {
color: #52c41a;
font-weight: bold;
}
.highlight-red {
color: #ff4d4f;
}
</style>
```
---
## 六、实现优先级
| 优先级 | 任务 | 涉及文件 | 预计工时 |
|--------|------|---------|---------|
| P0 | 数据库字段扩展 | `sql/20250527/01_material_alter.sql` | 0.5h |
| P0 | 物料列表页字段更新 | `material/index.vue` | 2h |
| P0 | 新增/编辑表单更新 | `material/index.vue` 对话框 | 3h |
| P1 | 性能参数动态表单组件 | `components/PerformanceParamsForm.vue` | 3h |
| P1 | 物料详情页框架 | `material/detail.vue` | 4h |
| P1 | Tab 1: 基础信息 | `components/BasicInfoTab.vue` | 2h |
| P1 | Tab 2: 供应商历史报价 | `components/SupplierQuoteTab.vue` | 3h |
| P2 | Tab 3: 甲方历史报价 | `components/ClientQuoteTab.vue` | 3h |
| P2 | Tab 4: 横向对比 | `components/ComparisonTab.vue` | 4h |
| P2 | 横向对比管理功能 | 添加/编辑/删除对标物料 | 3h |
| P3 | 智能推荐算法 | 综合评分计算 | 2h |
| P3 | 后端API实现 | `BizMaterialController.java` | 4h |
**总计**: 约 33.5 工时
---
## 七、注意事项
1. **数据迁移**: 执行SQL脚本前请备份数据库
2. **字段变更**:
- `spec` 字段保持为"规格型号"**不变更**
- `brand` 字段改为"厂家/品牌"**变更注释**
- `remark` 字段保持为"备注"**不变更**
3. **JSON存储**: 性能参数使用JSON格式便于灵活扩展
4. **权限控制**: 详情页需要单独配置菜单权限
5. **图片上传**: 物料图片使用单独的上传接口存储URL
---
## 八、相关文件
- SQL脚本: `sql/20250527/01_material_alter.sql`
- 开发文档: `doc/智采系统-物料信息模块开发文档.md`
- 完整方案: `doc/福安德智慧报价平台-完整开发方案.md`

View File

@@ -0,0 +1,957 @@
# 福安德智慧报价平台 — 完整开发方案
> 基于 RuoYi-Vue v3.9.2 (Spring Boot 4.0.3 + JDK 17 + Vue 2 + Element UI)
> 融合 PPT《报价比对系统》5大板块 + XLSX《电气自动化设备综合管理系统_V1》27个Sheet数据
> 对接现有项目12个已完成模块
---
## 目录
- [一、项目全景与数据资产](#一项目全景与数据资产)
- [二、板块一:物料信息](#二板块一物料信息)
- [三、板块二:物料详情](#三板块二物料详情)
- [四、板块三:供应商管理](#四板块三供应商管理)
- [五、板块四:报价商门户(供应商端)](#五板块四报价商门户供应商端)
- [六、板块五:甲方报价单](#六板块五甲方报价单)
- [七、报价单管理与指派系统](#七报价单管理与指派系统)
- [八、电气设备数据初始化方案](#八电气设备数据初始化方案)
- [九、开发优先级与里程碑](#九开发优先级与里程碑)
- [十、关键接口清单](#十关键接口清单)
- [十一、权限与安全设计](#十一权限与安全设计)
---
## 一、项目全景与数据资产
### 1.1 PPT五大板块映射
| PPT板块 | 对应功能 | 现有进度 | 开发类型 |
|---------|---------|---------|---------|
| 智采系统-物料信息 | 物料CRUD + 分类树 + 性能参数JSON | `biz_material` 已有基础字段 | 扩展增强 |
| 智采系统-物料详情 | 详情页 + 3个Tab(历史报价/甲方报价/同比) | 未开发 | 新建 |
| 智采系统-供应商管理 | 供货清单 + 报价单管理 + 指派界面 | `biz_supplier` 已有基础 | 扩展增强 |
| 智采系统-报价商 | 供应商门户(已报项/可报项/报价界面) | 未开发 | 新建 |
| 给甲方报价单 | 子RFQ拆解 + 成本回填 + 历史版本 + 横向对比 | `biz_client_quote` 基础已有 | 核心增强 |
### 1.2 XLSX数据资产清单
Excel中包含了完整的电气设备业务数据共27个Sheet
| Sheet | 名称 | 行数 | 用途 |
|-------|------|------|------|
| 1 | 00_导航首页 | 24 | 系统导航和功能入口说明 |
| 2 | 01_项目报价单(fad给甲方) | 52 | 甲方报价模板(含品牌/型号/数量/折扣率) |
| 3 | 02_报价汇总 | 27 | 按类汇总 + 加价系数 + 对外报价 |
| 4 | 10_变频器比价 | 48 | 汇川/禾望/西门子/ABB 三家比价 |
| 5 | 11_整流单元比价 | 37 | 整流/制动/回馈单元比价 |
| 6 | 12_PLC比价 | 15 | CPU+模块比价 |
| 7 | 13_低压电器比价 | 22 | 断路器/接触器/热继电器比价 |
| 8 | 14_电缆线槽比价 | 25 | 电缆/控制线/线槽综合比价 |
| 9-19 | 20_变频器型号库 ~ 30_网关型号库 | 321+ | 全品类型号数据库 |
| 20 | 31_品牌汇总 | 22 | 各品牌型号数量统计 |
### 1.3 核心业务流程
```
甲方需求 → 创建甲方报价单 → 拆解为内部RFQ(子报价单)
→ 指派供应商(可报项) → 供应商报价 → 比价选择
→ 成本回填甲方报价单 → 生成最终甲方报价 → 甲方确认
→ 生成采购单 → 交付 → 评价/异议处理
```
---
## 二、板块一:物料信息
### 2.1 现有基础
`biz_material` 表已有字段:
- `material_id`, `tenant_id`, `category_id`, `material_code`, `material_name`
- `spec`, `unit`, `brand`, `description`
- `status`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`
### 2.2 扩展字段
```sql
ALTER TABLE biz_material ADD COLUMN (
-- PPT要求字段
manufacturer VARCHAR(100) COMMENT '厂家/品牌(与brand一致作为冗余筛选)',
model_size VARCHAR(200) COMMENT '型号/尺寸(如MD500T37G, TMY-60×6)',
performance_params TEXT COMMENT '性能参数(JSON格式: {"功率":"37kW","电压":"380V"})',
material VARCHAR(100) COMMENT '材质(铜/铝合金/PVC等)',
purpose VARCHAR(500) COMMENT '用途(如"通用负载","风机水泵")',
remarks TEXT COMMENT '备注',
category_tree_path VARCHAR(500) COMMENT '分类路径(冗余,如"1/5/23")',
spec_comparison_group VARCHAR(100) COMMENT '横向对比分组标识',
-- 扩展业务字段
min_stock INT DEFAULT 0 COMMENT '最低库存预警',
unit_weight DECIMAL(10,4) COMMENT '单重(kg)',
image_url VARCHAR(500) COMMENT '物料图片',
data_source VARCHAR(20) DEFAULT 'manual' COMMENT '数据来源(manual/import/excel)',
version INT DEFAULT 1 COMMENT '乐观锁版本号'
);
-- 型号唯一索引(防止重复录入同规格物料)
ALTER TABLE biz_material ADD UNIQUE INDEX idx_model_manufacturer (manufacturer, model_size);
```
### 2.3 性能参数JSON规范
性能参数使用JSON格式存储前端动态渲染为键值对表格
```json
// 变频器示例
{
"功率": "37kW",
"电压": "380V",
"电流": "72A",
"过载能力": "150%/60s",
"通讯接口": "RS485/PROFINET",
"防护等级": "IP20"
}
// 电缆示例
{
"芯数×截面": "3×70+1×35",
"额定电压": "0.6/1kV",
"外径": "42mm",
"载流量": "210A",
"护套材质": "PVC铠装"
}
```
**前端组件**使用Element UI动态表单`el-form` + `el-table`),每行=参数名+参数值+单位支持增删行。保存时组装为JSON字符串读取时`JSON.parse`后渲染为只读表格。
### 2.4 前端页面结构
```
物料信息列表页 (/bid/material)
├── 左侧:物料分类树 (复用 biz_material_category)
│ ├── 树节点点击 → 右侧表格过滤
│ └── 面包屑导航(基于category_tree_path)
├── 右侧:物料表格
│ ├── 顶部筛选栏
│ │ ├── 厂家下拉(去重,从manufacturer字段DISTINCT)
│ │ ├── 型号模糊搜索(输入前缀智能联想)
│ │ ├── 分类级联选择器
│ │ └── 关键词搜索
│ ├── 表格列:编码 | 名称 | 厂家 | 型号 | 材质 | 用途 | 单位 | 操作
│ └── 操作按钮:详情 | 编辑 | 删除 | 加入对比篮
└── 新增/编辑对话框(抽屉形式)
├── 基础信息(名称/分类/厂家/型号/材质/用途/单位)
├── 性能参数动态表单(支持增删行)
├── 规格明细(可扩展多规格)
└── 图片上传(物料图/铭牌)
```
### 2.5 后端接口扩展
`BizMaterialController` 中新增:
| 接口 | 方法 | 说明 |
|------|------|------|
| `/bid/material/detail/{id}` | GET | 物料详情(含性能参数解析+历史报价摘要) |
| `/bid/material/manufacturer/list` | GET | 厂家下拉列表(去重) |
| `/bid/material/import` | POST | Excel批量导入(EasyExcel解析) |
| `/bid/material/export` | GET | 导出为Excel模板 |
| `/bid/material/suggest` | GET | 型号智能联想(q参数前缀匹配) |
| `/bid/material/comparison-group/{groupId}` | GET | 按对比组获取物料 |
---
## 三、板块二:物料详情
### 3.1 页面架构
采用"1+N"信息架构——1个基础信息区 + N个动态关联Tab
```
物料详情页 (/bid/material/detail/:id)
├── 顶部基础信息卡片(两栏)
│ ├── 左栏(16/24):基础信息表(物料ID/厂家/型号/材质/用途)
│ │ └── 性能参数表格(从JSON动态渲染)
│ └── 右栏(8/24)价格趋势ECharts迷你折线图
│ └── 近6个月最低报价走势(从biz_quotation_item聚合)
├── Tab 1供应商历史报价清单
├── Tab 2我方给甲方的历史报价
└── Tab 3不同品牌/规格同比
```
### 3.2 Tab 1供应商历史报价清单
**数据来源**`biz_quotation_item``biz_quotation``biz_supplier`
**展示字段**
| 字段 | 说明 |
|------|------|
| 报价日期 | biz_quotation.submit_time |
| 供应商名称 | biz_supplier.supplier_name |
| 单价(元) | biz_quotation_item.unit_price |
| 数量 | biz_quotation_item.quantity |
| 交期(周) | biz_quotation_item.delivery_days / 7 |
| 报价单号 | biz_quotation.quote_no |
| 操作 | 查看详情(点击下钻到报价单) |
**关键交互**
- 点击单价 → 抽屉展示原始报价单详情
- 价格预警高于近3个月平均价15% → 行背景红色高亮
- 按时间倒序排列
**后端API**
```java
// BizMaterialController
@GetMapping("/{id}/supplier-quotes")
public AjaxResult getSupplierQuoteHistory(@PathVariable Long id) {
// 查询 biz_quotation_item JOIN biz_quotation JOIN biz_supplier
// WHERE material_id = id ORDER BY submit_time DESC
}
```
### 3.3 Tab 2我方给甲方的历史报价
**数据来源**`biz_client_quote_item``biz_client_quote`
**展示字段**
| 字段 | 说明 |
|------|------|
| 报价日期 | client_quote.create_time |
| 甲方名称 | client_quote.client_name |
| 我方含税单价 | client_quote_item.unit_price |
| 折扣率 | client_quote_item.discount_rate |
| 最终成交价 | 含税单价 × 折扣率 |
| 状态 | sent/accepted/rejected(带标签) |
| 关联RFQ | client_quote.rfq_id → biz_rfq.rfq_no |
**功能扩展**
- 落选标注原因(新增client_quote_item.reject_reason字段)
- 版本对比(基于parent_quote_id)
### 3.4 Tab 3不同品牌/规格同比
#### 新建对比关系表
```sql
CREATE TABLE biz_material_comparison (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
group_id VARCHAR(50) NOT NULL COMMENT '对比组ID(如"VFD-37kW-380V")',
group_name VARCHAR(200) COMMENT '对比组名称(如"37kW 380V变频器对比组")',
material_id BIGINT NOT NULL COMMENT '物料ID',
counterpart_brand VARCHAR(100) COMMENT '对标品牌',
counterpart_model VARCHAR(200) COMMENT '对标型号',
comparison_notes TEXT COMMENT '对比说明(差异点)',
sort_order INT DEFAULT 0 COMMENT '组内排序',
status CHAR(1) DEFAULT '0' COMMENT '状态',
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME,
UNIQUE KEY uk_group_material (group_id, material_id),
KEY idx_material (material_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物料横向对比组';
```
**功能逻辑**
1. **管理员维护对比组**(在物料详情页可直接操作)
- 为某个物料添加"对标物料"(跨品牌)
- 如汇川MD500T37G ↔ 禾望HV500-37G ↔ 西门子G120 PM240 37kW
2. **自动对比展示**
- 系统自动从 biz_comparison 对比结果中拉取对标物料的最新供应商报价
- 对比维度:单价 | 功率 | 过载能力 | 通讯接口 | 防护等级 | 适用场合
3. **数据初始化**
- 利用Excel Sheet 10-14的比价数据预置对比组
- 如Sheet 10中"37kW变频器"对应的汇川/禾望/西门子/ABB四家数据
#### 前端展示
```
┌─────────────────────────────────────────────────────────────┐
│ 37kW 380V变频器 对比组 │
├──────────────┬──────────┬──────────┬──────────┬────────────┤
│ │ 汇川 │ 禾望 │ 西门子 │ ABB │
│ │ MD500T37G │ HV500-37G│ G120 37kW│ ACS880-01 │
├──────────────┼──────────┼──────────┼──────────┼────────────┤
│ 最低报价(元) │ 6,200 │ 5,800 │ 10,500 │ 8,900 │
│ 功率(kW) │ 37 │ 37 │ 37 │ 37 │
│ 过载能力 │ 150%/60s │ 150%/60s │ 140%/60s │ 150%/60s │
│ 通讯接口 │ RS485 │ RS485 │ PROFINET │ PROFINET │
│ 防护等级 │ IP20 │ IP20 │ IP20 │ IP21 │
│ 综合评价 │ ★★★★☆ │ ★★★★★ │ ★★★☆☆ │ ★★★★☆ │
├──────────────┼──────────┴──────────┴──────────┴────────────┤
│ 推荐选型 │ 禾望HV500-37G (性价比最优) │
└──────────────┴─────────────────────────────────────────────┘
```
---
## 四、板块三:供应商管理
### 4.1 现有基础
`biz_supplier` 表已有字段supplier_id, supplier_name, contact, phone, email, address, status
### 4.2 供应商详情页重构
在现有 `/bid/supplier` 基础上,将列表页改为"列表+详情"结构,点击供应商进入详情页:
```
供应商详情页 (/bid/supplier/detail/:id)
├── Tab 1: 基础信息 (现有功能)
│ ├── 供应商名称/联系人/电话/邮箱/地址
│ └── 资质文件上传(营业执照/授权证书)
├── Tab 2: 供货清单 (新建)
│ ├── 供应商-物料关系维护
│ └── 历史报价快速查看
├── Tab 3: 报价单管理 (增强)
│ ├── 参与的RFQ列表
│ └── 报价单状态跟踪
├── Tab 4: 历史报价趋势 (新建)
│ └── ECharts折线图
└── Tab 5: 账号管理 (新建)
└── 供应商登录账号创建/权限管理
```
### 4.3 供货清单
#### 新建供应商-物料关系表
```sql
CREATE TABLE biz_supplier_material (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
supplier_id BIGINT NOT NULL,
material_id BIGINT NOT NULL,
supply_status TINYINT DEFAULT 1 COMMENT '0停供 1正常 2备选',
last_quote_price DECIMAL(18,4) COMMENT '最新报价(缓存)',
last_quote_time DATETIME COMMENT '最新报价时间',
delivery_cycle INT DEFAULT 0 COMMENT '供应周期(天)',
min_order_qty DECIMAL(15,4) DEFAULT 1 COMMENT '最小起订量',
preferred TINYINT DEFAULT 0 COMMENT '是否优选供应商',
create_time DATETIME,
UNIQUE KEY uk_supplier_material (supplier_id, material_id),
KEY idx_supplier (supplier_id),
KEY idx_material (material_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商物料供应关系';
```
**前端交互**
- 点击物料名称 → 跳转物料详情页(`/bid/material/detail/:id`)
- 支持在物料详情页"添加供应商"反向维护
- 批量导入根据Excel比价数据自动建立供应关系
### 4.4 报价单管理
供应商参与的RFQ及报价单管理增强现有 `biz_rfq`
```sql
ALTER TABLE biz_rfq ADD COLUMN (
contract_no VARCHAR(100) COMMENT '合同号',
client_name VARCHAR(100) COMMENT '甲方名称',
project_name VARCHAR(200) COMMENT '项目名称',
delivery_required_date DATE COMMENT '要求交货日期',
delivery_weeks INT COMMENT '要求交货周期(周)',
quote_deadline DATETIME COMMENT '供应商报价截止日',
total_budget DECIMAL(18,4) COMMENT '预算总额',
winner_supplier_id BIGINT COMMENT '中标供应商ID',
winner_quotation_id BIGINT COMMENT '中标报价单ID'
);
```
**报价单列表页字段**:询价单号 | 合同号 | 甲方 | 项目名称 | 状态 | 报价截止日 | 已报价/邀请数 | 操作
**状态流转**`draft(草稿) → published(已发布) → in_progress(报价中) → closed(已关闭) → completed(已完成)`
### 4.5 指派供应商界面
**触发点**RFQ发布时选择被邀请的供应商
```
┌─ 指派供应商对话框 ─────────────────────────────────┐
│ │
│ 询价单号: RFQ-2026-001 │
│ 标题: 自动化生产线控制系统采购 │
│ │
│ ☐ 全选 搜索供应商: [______] │
│ │
│ ☑ 深圳市华顺达电子有限公司 上次合作: 2026-01 │
│ ☑ 苏州博远精密设备有限公司 上次合作: 2026-01 │
│ ☐ 上海瑞达工控科技有限公司 新供应商 │
│ ☑ 广州联盟电气集团 报价历史优秀 │
│ ☐ 武汉华中精工机械有限公司 评价4.8分 │
│ │
│ 发送方式: [📧 邮件通知] [💬 站内信] [📱 短信通知] │
│ │
│ [取消] [确认指派] │
└─────────────────────────────────────────────────────┘
```
**指派后**:系统向供应商发送通知,供应商登录后可看到"可报项"
---
## 五、板块四:报价商门户(供应商端)
### 5.1 账户体系设计
```sql
-- 在 sys_user 增加供应商类型字段(已有 tenant_id)
ALTER TABLE sys_user ADD COLUMN (
user_type TINYINT DEFAULT 1 COMMENT '1内部员工 2供应商账号',
supplier_id BIGINT COMMENT '关联供应商ID(仅user_type=2时有效)',
account_status TINYINT DEFAULT 1 COMMENT '0待审核 1正常 2冻结'
);
```
**权限控制**
- 供应商账号(`user_type=2`) → 仅能看到本供应商数据(通过`supplier_id`过滤)
- RuoYi `@PreAuthorize`注解 + 自定义数据权限拦截器
- 路由层面:供应商端访问 `/supplier/**` 路径,内部员工无法访问
### 5.2 供应商路由体系
新建独立路由模块,与后台管理路由隔离:
```javascript
// ruoyi-ui/src/router/supplier.js
export const supplierRoutes = [
{
path: '/supplier',
component: Layout,
hidden: true, // 不在管理后台侧边栏显示
children: [
{ path: 'dashboard', component: () => import('@/views/supplier/dashboard'), meta: { title: '供应商首页' } },
{ path: 'rfq/pending', component: () => import('@/views/supplier/rfq-pending'), meta: { title: '可报项' } },
{ path: 'rfq/quoted', component: () => import('@/views/supplier/rfq-quoted'), meta: { title: '已报项' } },
{ path: 'quote/edit/:rfqId', component: () => import('@/views/supplier/quote-edit'), meta: { title: '报价填写' } },
{ path: 'profile', component: () => import('@/views/supplier/profile'), meta: { title: '我的信息' } },
]
}
];
```
### 5.3 供应商首页 (`/supplier/dashboard`)
```
┌────────────────────────────────────────────────────────────┐
│ 欢迎您,深圳市华顺达电子有限公司 │
├──────────┬──────────┬──────────┬──────────┬───────────────┤
│ 可报项 │ 已报项 │ 中标数 │ 中标率 │ 累计成交额 │
│ 3 │ 12 │ 5 │ 41.7% │ ¥1,286,500 │
├──────────┴──────────┴──────────┴──────────┴───────────────┤
│ ⏰ 紧急提醒RFQ-2026-003 报价截止还剩 2天
│ 📋 最新中标的报价单RFQ-2026-001 (2026-01-15中标) │
└────────────────────────────────────────────────────────────┘
```
### 5.4 可报项 (`/supplier/rfq/pending`)
**数据来源**`biz_rfq_supplier` WHERE `supplier_id = ?` AND `status = 'published'` AND `quote_deadline > NOW()`
**列表字段**
| 字段 | 说明 |
|------|------|
| RFQ编号 | biz_rfq.rfq_no |
| 甲方/项目 | biz_rfq.client_name / project_name |
| 报价截止日 | biz_rfq.quote_deadline (红色<3天) |
| 物料数量 | biz_rfq_item COUNT |
| 操作 | 立即报价(按钮) |
**紧急提醒**截止前24小时发送站内信(复用RuoYi消息中心)
### 5.5 已报项 (`/supplier/rfq/quoted`)
**数据来源**`biz_quotation` WHERE `supplier_id = ?`
**列表字段**
| 字段 | 说明 |
|------|------|
| RFQ编号 | rfq_no |
| 报价日期 | quotation.create_time |
| 物料数量 | quotation_item COUNT |
| 报价总额 | quotation.total_amount |
| 状态 | pending(待开标)/won(已中标)/lost(未中标) |
| 操作 | 查看详情 / 修改报价(截止前可改) |
### 5.6 报价填写界面 (`/supplier/quote/edit/:rfqId`)
采用**分步表单**
```
Step 1 → 物料清单确认 (只读展示RFQ物料)
Step 2 → 逐项填价 (核心步骤)
Step 3 → 预览提交
```
**Step 2 填价界面**
```
物料清单表格:
┌──────────┬────────┬──────┬──────┬──────────┬────────┬──────────┐
│ 物料名称 │ 规格 │ 数量 │ 单位 │ 单价(元)* │ 交期(周)│ 备注 │
├──────────┼────────┼──────┼──────┼──────────┼────────┼──────────┤
│ PLC控制器 │ CPU... │ 5 │ 套 │ [___] │ [___] │ [______] │
│ 变频器 │ 三相...│ 8 │ 台 │ [___] │ [___] │ [______] │
│ 电源模块 │ 24V... │ 20 │ 台 │ [___] │ [___] │ [______] │
└──────────┴────────┴──────┴──────┴──────────┴────────┴──────────┘
含税/未税切换: [●含税] [○未税] 自动计算增值税13%
```
**关键交互**
- 必填校验单价 > 0交期 ≤ RFQ要求
- 智能提示当输入单价低于该物料历史最低价的80%时 → 警告弹窗"报价异常偏低,请确认"
- 附件上传:产品彩页、资质证书、型式试验报告(新表`biz_quotation_attachment`)
- 自动保存草稿(LocalStorage + 后端每3分钟自动保存)
- 提交前预览比价结果(提示"您的报价在当前排名第X/共Y家")
### 5.7 供应商端API
| 接口 | 方法 | 说明 |
|------|------|------|
| `/supplier/dashboard/stats` | GET | 统计数据总览 |
| `/supplier/rfq/pending` | GET | 可报项列表(分页) |
| `/supplier/rfq/quoted` | GET | 已报项列表(分页) |
| `/supplier/quote/draft/{rfqId}` | GET | 获取草稿报价 |
| `/supplier/quote/save-draft` | POST | 保存报价草稿 |
| `/supplier/quote/submit` | POST | 提交报价 |
| `/supplier/quote/{id}/attachments` | POST | 上传附件 |
| `/supplier/profile` | GET/PUT | 供应商信息维护 |
---
## 六、板块五:甲方报价单
### 6.1 现有基础
`biz_client_quote` 已有基础CRUD`biz_client_quote_item` 已有明细表。现有页面已支持基本的新建/编辑/PDF导出。
### 6.2 数据模型增强
```sql
-- 增强甲方报价单
ALTER TABLE biz_client_quote ADD COLUMN (
rfq_id BIGINT COMMENT '关联内部RFQ',
cost_total DECIMAL(18,4) COMMENT '内部成本合计(供应商中标价汇总)',
margin_rate DECIMAL(5,2) COMMENT '毛利率%',
quote_version INT DEFAULT 1 COMMENT '报价版本号',
parent_quote_id BIGINT COMMENT '父报价单ID(版本迭代)',
project_name VARCHAR(200) COMMENT '项目名称',
validity_date DATETIME COMMENT '报价有效期',
payment_terms VARCHAR(500) COMMENT '付款条件',
delivery_terms VARCHAR(500) COMMENT '交货条款',
status VARCHAR(20) COMMENT 'draft/sent/accepted/rejected/revised',
margin_rate_setting DECIMAL(5,2) DEFAULT 15.00 COMMENT '加价系数(%)',
tax_rate DECIMAL(4,2) DEFAULT 13.00 COMMENT '税率(%)'
);
-- 新建甲方报价-物料-供应商关联表(记录每个物料选定的供应商)
CREATE TABLE biz_client_quote_item_source (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
quote_item_id BIGINT NOT NULL COMMENT 'biz_client_quote_item.id',
supplier_id BIGINT COMMENT '选定的供应商ID',
supplier_quote_id BIGINT COMMENT '供应商报价单ID(biz_quotation.quotation_id)',
unit_cost DECIMAL(18,4) COMMENT '供应商中标单价(含税)',
selected_reason VARCHAR(500) COMMENT '选择该供应商原因',
internal_note TEXT COMMENT '内部备注',
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME,
KEY idx_quote_item (quote_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='甲方报价物料来源';
```
### 6.3 业务流程图
```
┌──────────────┐
│ 甲方询价需求 │
└──────┬───────┘
┌──────────────────┐
│ 创建甲方报价单 │ ← 手动输入甲方需求物料
│ (biz_client_quote)│
└──────┬───────────┘
┌──────────────────┐
│ 生成内部RFQ │ ← 从甲方物料自动拆解为RFQ
│ (点击"发起询价") │ 按品类分多个子RFQ
└──────┬───────────┘
┌──────────────────┐
│ 指派供应商报价 │ ← 每个子RFQ指派不同供应商
│ (供应商端报价) │ 变频器→A/B/C, 电缆→D/E
└──────┬───────────┘
┌──────────────────┐
│ 比价选择供应商 │ ← 使用现有智慧比价引擎
│ (选定最优方案) │
└──────┬───────────┘
┌──────────────────┐
│ 成本回填 │ ← 自动回填选中供应商价格到
│ (自动/手动同步) │ biz_client_quote_item_source
└──────┬───────────┘
┌──────────────────┐
│ 计算对外报价 │ ← 成本 × (1 + 加价系数)
│ 生成PDF → 发送甲方 │ 可手动调整覆盖
└──────────────────┘
```
### 6.4 甲方报价单详情页重构
```
甲方报价单详情页 (/bid/clientquote/detail/:id)
├── 顶部操作栏
│ ├── 保存草稿 | 导出PDF | 发送甲方 | 版本迭代
│ └── 状态标签(draft/sent/accepted/rejected)
├── 基本信息卡片
│ ├── 客户名称 | 项目名称 | 有效期 | 币种
│ ├── 关联RFQ(下拉选择) | 合同号
│ └── 付款条件 | 交货条款
├── 物料清单表格(可编辑)
│ ├── 行数据:序号 | 类别 | 物料名称 | 品牌 | 型号规格
│ │ | 数量 | 单位 | 供应商中标价(成本)
│ │ | 加价系数 | 对外含税单价 | 小计
│ ├── 操作:添加行 | 删除行 | 批量导入
│ └── 特殊列:询价状态(未询价/报价中/已回价/已定价)
│ └── 已定价 → 显示供应商名称,点击查看来源
│ └── 未询价 → 点击"发起询价"→ 弹出选择供应商 → 生成子RFQ
├── 右侧成本汇总面板(固定)
│ ├── 物料成本合计: ¥XXX (自动汇总供应商中标价)
│ ├── 加价系数: 15% (可调整)
│ ├── 对外报价(含税): ¥XXX = 成本 × (1 + 加价系数)
│ ├── 预计毛利率: XX.X% (实时计算)
│ └── 操作按钮:同步最新采购价 | 重新计算
├── Tab: 智能选型对比
│ └── 选中物料 → 弹出对比窗口 → 展示同规格多品牌对比
│ → 一键替换物料型号和成本
└── Tab: 历史版本
└── 版本列表(通过parent_quote_id关联)
→ 版本对比(高亮显示差异)
```
### 6.5 子RFQ生成逻辑
```java
// 从甲方报价单生成内部RFQ
@PostMapping("/bid/clientquote/{id}/generateRfq")
public AjaxResult generateSubRfq(@PathVariable Long id, @RequestBody GenerateRfqRequest req) {
// 1. 获取甲方报价单主表
// 2. 根据选中的行物料,按category_id分组
// 如: 变频器类一组,PLC类一组,电缆类一组
// 3. 为每组创建子RFQ:
// - rfq_title = 甲方报价单项目名 + "(" + 类别名称 + ")"
// - deadline = 甲方报价单有效期前7天
// - parent_client_quote_id = 甲方报价单ID
// 4. 将选中物料复制到biz_rfq_item
// 5. 指派供应商(从req.supplier_ids)
// 6. 返回生成的RFQ ID列表
}
```
### 6.6 成本回填机制
**自动回填触发点**
1. 采购单确认时(`biz_purchase_order.status → 'confirmed'`)
2. 甲方报价单页面手动点击"同步最新采购价"
**回填逻辑**
```java
// 自动回填
@PostMapping("/bid/clientquote/{id}/syncCost")
public AjaxResult syncCost(@PathVariable Long id) {
// 1. 获取所有子RFQ的最新成交报价
// 2. 将选中供应商的成交价写入biz_client_quote_item_source
// 3. 更新biz_client_quote.cost_total
// 4. 重新计算margin_rate
// 5. 记录价格变动日志
}
```
**价格变动日志表**
```sql
CREATE TABLE biz_price_change_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_type VARCHAR(20) COMMENT '来源类型(quotation/client_quote)',
source_id BIGINT COMMENT '来源ID',
material_id BIGINT COMMENT '物料ID',
old_price DECIMAL(18,4),
new_price DECIMAL(18,4),
change_reason VARCHAR(500) COMMENT '变更原因',
operator VARCHAR(64),
create_time DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价格变动日志';
```
### 6.7 两个报价历史
**历史1我方给甲方的报价历史**
- 数据源:`biz_client_quote` 通过 `parent_quote_id` 形成版本链
- 功能:版本对比 → 选择两个版本高亮显示价格差异
**历史2供应商给我方的报价历史**
- 数据源:`biz_quotation_item` 中该物料的历史记录
- 功能:在甲方报价单物料行点击"查看成本来源" → 抽屉展示时间轴
---
## 七、报价单管理与指派系统
### 7.1 报价单详情页 (`/bid/rfq/detail/:id`)
```
RFQ详情页 (/bid/rfq/detail/:id)
├── 顶部:基础信息(合同号/甲方/截止日/状态)
├── 左侧:已邀请供应商列表
│ ├── 供应商名称 | 报价状态(未报价/已报价/已逾期)
│ ├── 已报价 → 显示报价金额及操作(查看详情)
│ └── 未报价 → 显示"催办"按钮(发送通知)
├── 右侧:物料明细及各家报价对比表格
│ ├── 与XLSX Sheet 10-14格式一致
│ ├── 行:物料,列:各供应商单价(最低价绿色高亮)
│ └── 差额/差价自动计算
├── 底部操作栏
│ ├── 确认中标 → 弹出选择中标供应商对话框
│ ├── 重新询价 → 复制当前RFQ生成新版本
│ ├── 导出比价报告(PDF) → 复用现有导出功能
│ └── 关闭询价
```
### 7.2 供应商报价填写界面(供应商端)
见5.6节详细描述。
### 7.3 比价报告PDF
模板格式参考XLSX Sheet 10-14包含
- 表头RFQ编号、项目名称、日期
- 物料对比表:每行展示各供应商报价、最低价标记
- 推荐方案:综合评分推荐最优供应商
- 统计摘要:总价对比、节省金额
---
## 八、电气设备数据初始化方案
### 8.1 数据范围
利用XLSX Sheet 20-31的型号库数据预置如下初始数据
| 品类 | 型号数量 | 数据来源Sheet | 对应品牌 |
|------|---------|--------------|---------|
| 变频器 | ~322 | 31_品牌汇总 | 汇川/禾望/西门子/ABB |
| 整流制动单元 | ~100 | 21_整流制动单元库 | 汇川/禾望/西门子 |
| PLC CPU | ~55 | 22_PLC_CPU型号库 | 西门子/三菱/欧姆龙 |
| PLC扩展模块 | ~98 | 23_PLC_扩展模块库 | 西门子/三菱/欧姆龙 |
| 低压电器 | ~75 | 24_低压电器型号库 | 正泰/施耐德 |
| 触摸屏 | ~20 | 25_触摸屏型号库 | 威纶通/昆仑通态/西门子 |
| 铜牌母线 | ~20 | 26_铜牌母线型号库 | TMY铜排 |
| 动力电缆 | ~23 | 27_电缆型号库 | YJV22系列 |
| 控制线 | ~21 | 28_控制线型号库 | KVVP系列 |
| 线槽 | ~18 | 29_线槽型号库 | PVC/钢制 |
| 工业网关 | ~12 | 30_网关型号库 | 汇川/研华 |
| **合计** | **~764** | | |
### 8.2 供应商初始化
基于XLSX比价数据中的供应商初始化以下供应商
| 供应商 | 供应品类 | 数据范围 |
|--------|---------|---------|
| A供应商(汇川/正泰等) | 变频器/整流/低压电器 | Sheet 10-14 |
| B供应商(禾望等) | 变频器/整流/PLC | Sheet 10-14 |
| C供应商(西门子/施耐德等) | 变频器/PLC/低压电器 | Sheet 10-14 |
### 8.3 对比组初始化
根据Sheet 10-14比价数据建立初始对比组
| 对比组 | 包含品牌 | 数据来源 |
|--------|---------|---------|
| 37kW 380V变频器 | 汇川MD500T37G / 禾望HV500-37G / 西门子G120 / ABB ACS880 | Sheet 10 |
| 55kW 380V变频器 | 汇川MD500T55G / 禾望HV500-55G / 西门子G120 PM240 | Sheet 10 |
| 整流单元75kW | MDR100T75 / HBD-075 / BLM-075 | Sheet 11 |
| CPU 1515-2 PN | 三家供应商报价对比 | Sheet 12 |
| 断路器NXM-125S | 正泰/施耐德/其他 | Sheet 13 |
| 动力电缆YJV 3×70+1×35 | 三家供应商报价对比 | Sheet 14 |
### 8.4 导入策略
**方案一SQL脚本导入推荐首批**
- 将型号库数据编写为 `INSERT` 语句
- 写入 `sql/demo_data.sql` 或新建 `sql/excel_import_data.sql`
- 每个物料生成 `material_code` (如 `M-EL-001`)
**方案二Excel批量导入功能后续维护**
- 通过 `/bid/material/import` 接口
- 上传Excel文件 → EasyExcel解析 → 数据校验 → 批量写入
- 提供下载导入模板功能
---
## 九、开发优先级与里程碑
### 第一阶段基础数据层第1-2周
| 优先级 | 任务 | 涉及文件 |
|--------|------|---------|
| P0 | 物料表字段扩展 + 性能参数JSON动态表单 | `biz_material` + 前端物料表单 |
| P0 | 物料分类树+表格联动 | 复用现有 `BizMaterialCategoryController` |
| P1 | 物料详情页基础框架 + Tab1(供应商历史报价) | 新建 `material/detail.vue` |
| P1 | 供应商-物料关系表 + 供货清单Tab | 新建 `biz_supplier_material` |
| P1 | 物料横向对比组表 + 维护界面 | 新建 `biz_material_comparison` |
| P2 | Excel电气设备型号库数据导入(首批~764条) | 扩充 `demo_data.sql` |
### 第二阶段业务流程层第3-5周
| 优先级 | 任务 | 涉及文件 |
|--------|------|---------|
| P0 | RFQ增强(合同号/甲方/截止日/指派界面) | `biz_rfq` 扩展 + RFQ详情页重构 |
| P0 | 供应商门户独立路由 + 可报项/已报项列表 | 新建 `views/supplier/*` |
| P0 | 供应商报价填写界面(分步表单) | 新建 `supplier/quote-edit.vue` |
| P1 | 供应商账号管理(账号创建/权限分配) | `sys_user` user_type扩展 |
| P1 | 甲方报价单增强(子RFQ生成) | `BizClientQuoteController` |
| P1 | 成本回填机制(自动+手动触发) | 新建 `biz_client_quote_item_source` |
| P2 | 物料详情Tab2(甲方历史报价) | 扩展 `material/detail.vue` |
### 第三阶段智能分析层第6-8周
| 优先级 | 任务 | 涉及文件 |
|--------|------|---------|
| P1 | 物料详情Tab3(横向同比+推荐算法) | 扩展 `material/detail.vue` |
| P1 | 甲方报价单智能选型对比 | `BizComparisonService` 扩展 |
| P2 | 价格趋势ECharts折线图(物料详情页) | 物料详情右栏 |
| P2 | 供应商报价历史趋势ECharts | 供应商详情Tab4 |
| P2 | 价格预警机制(高于均价15%红色高亮) | 前端表格渲染 |
| P3 | 比价报告PDF增强 | 复用现有JSPDF导出 |
### 第四阶段优化完善第9-10周
| 优先级 | 任务 | 涉及文件 |
|--------|------|---------|
| P2 | 版本对比(甲方报价单历史版本diff) | 扩展 `client-quote` |
| P2 | 批量导入Excel型号库(维护界面) | `/bid/material/import` |
| P3 | 供应商端多语言/国际化 | i18n配置 |
| P3 | 性能优化(物料列表大数据量分页) | 后端分页+前端虚拟滚动 |
| P3 | Docker部署配置更新 | `deploy/` |
---
## 十、关键接口清单
### 物料模块
| 接口 | 方法 | 说明 | 优先级 |
|------|------|------|--------|
| `/bid/material/detail/{id}` | GET | 物料详情+历史报价摘要 | P0 |
| `/bid/material/{id}/supplier-quotes` | GET | 供应商历史报价清单 | P0 |
| `/bid/material/{id}/client-quotes` | GET | 我方给甲方历史报价 | P1 |
| `/bid/material/{id}/comparison` | GET | 横向对比数据 | P1 |
| `/bid/material/manufacturer/list` | GET | 厂家下拉列表(去重) | P0 |
| `/bid/material/import` | POST | Excel批量导入 | P1 |
| `/bid/material/export` | GET | 导出模板 | P1 |
| `/bid/material/suggest` | GET | 型号智能联想 | P2 |
### 供应商模块
| 接口 | 方法 | 说明 | 优先级 |
|------|------|------|--------|
| `/bid/supplier/{id}/materials` | GET | 供货清单(分页) | P0 |
| `/bid/supplier/{id}/materials` | POST | 添加供应物料 | P1 |
| `/bid/supplier/{id}/quotes` | GET | 报价单管理列表 | P1 |
| `/bid/supplier/{id}/price-trend` | GET | 历史报价趋势 | P2 |
| `/bid/supplier/{id}/accounts` | GET | 供应商账号列表 | P1 |
| `/bid/supplier/{id}/accounts` | POST | 创建供应商账号 | P2 |
### RFQ模块
| 接口 | 方法 | 说明 | 优先级 |
|------|------|------|--------|
| `/bid/rfq/{id}/assign` | POST | 指派供应商 | P0 |
| `/bid/rfq/{id}/comparison` | GET | 比价结果(现有增强) | P0 |
| `/bid/rfq/{id}/bid-report` | GET | 导出比价报告PDF | P1 |
| `/bid/rfq/{id}/winner` | POST | 确认中标供应商 | P1 |
### 供应商端
| 接口 | 方法 | 说明 | 优先级 |
|------|------|------|--------|
| `/supplier/dashboard/stats` | GET | 统计数据总览 | P0 |
| `/supplier/rfq/pending` | GET | 可报项列表 | P0 |
| `/supplier/rfq/quoted` | GET | 已报项列表 | P0 |
| `/supplier/quote/draft/{rfqId}` | GET | 获取草稿报价 | P0 |
| `/supplier/quote/save-draft` | POST | 保存报价草稿 | P0 |
| `/supplier/quote/submit` | POST | 提交报价 | P0 |
| `/supplier/quote/{id}/attachments` | POST | 上传附件 | P1 |
### 甲方报价单
| 接口 | 方法 | 说明 | 优先级 |
|------|------|------|--------|
| `/bid/clientquote/{id}/generateRfq` | POST | 生成子RFQ | P0 |
| `/bid/clientquote/{id}/syncCost` | POST | 同步采购成本 | P1 |
| `/bid/clientquote/{id}/compareSpec` | GET | 智能选型对比 | P1 |
| `/bid/clientquote/{id}/versions` | GET | 历史版本列表 | P2 |
| `/bid/clientquote/version-compare` | POST | 版本对比diff | P2 |
| `/bid/clientquote/{id}/export-pdf` | GET | 导出PDF | P0(现有) |
---
## 十一、权限与安全设计
### 11.1 权限矩阵
| 角色 | 物料 | 供应商 | RFQ | 供应商报价 | 比价 | 甲方报价 | 供应商门户 |
|------|------|--------|-----|-----------|------|---------|-----------|
| 管理员 | CRUD | CRUD | CRUD | CRUD | 查看 | CRUD | 不可访问 |
| 采购员 | 查看 | 查看/编辑 | CRUD | 查看 | 查看/操作 | 查看 | 不可访问 |
| 销售员 | 查看 | 查看 | 查看 | 查看 | 查看 | 创建/编辑 | 不可访问 |
| 供应商 | 查看(仅关联) | 查看(仅自己) | 查看(受邀) | 查看(仅自己) | 不可见 | 不可见 | 报价/查看 |
### 11.2 数据权限
- **租户隔离**:所有业务表已有 `tenant_id`查询SQL必须带 `tenant_id = ?`
- **供应商隔离**:供应商账号 `user_type=2`,所有查询追加 `supplier_id = ?`
- **价格脱敏**:供应商看不到其他供应商的报价价格(但可以看到自己的排名)
### 11.3 安全措施
- **价格字段加密**敏感价格字段在数据库中使用AES加密存储接口返回时按角色脱敏
- **乐观锁防并发**`biz_quotation` 增加 `version` 字段供应商报价提交时CAS检查
- **操作日志**所有CRUD操作使用RuoYi `@Log` 注解记录
- **文件上传校验**:限制附件类型(pdf/jpg/png)大小≤10MB
---
## 附录Excel数据对照表
### XLSX → 系统表映射
| Excel Sheet | 对应系统表 | 说明 |
|------------|-----------|------|
| 01_项目报价单 | `biz_client_quote` + `biz_client_quote_item` | 甲方报价模板映射 |
| 02_报价汇总 | `biz_client_quote` (汇总字段) | 汇总逻辑直接实现 |
| 10-14_比价表 | `biz_quotation` + `biz_comparison`(已有) | 比价数据初始化来源 |
| 20-30_型号库 | `biz_material` | 初始物料数据(764条) |
| 31_品牌汇总 | 统计查询 | 直接SQL统计实现 |
### XLSX比价表 → 系统比价引擎字段映射
| Excel列 | 系统字段 | 说明 |
|---------|---------|------|
| A供应商型号 | quotation_item.material_name/spec | 报价物料 |
| A供应商报价 | quotation_item.unit_price | 供应商A报价单价 |
| B供应商型号 | quotation_item.material_name/spec | 报价物料 |
| B供应商报价 | quotation_item.unit_price | 供应商B报价单价 |
| 最低价 | BizComparisonVO.minPrice | 当前最低价(系统自动计算) |
| 推荐 | BizComparisonVO.recommendedSupplier | 评分最优推荐 |
---
> 本文档基于PPT《报价比对系统》(6页) + XLSX《电气自动化设备综合管理系统_V1》(27Sheet) + 现有项目源码(RuoYi-Vue v3.9.2, 12个已完成模块)综合分析生成,可直接作为迭代开发的技术参考。
>
> 最后更新2026-05-27

View File

@@ -54,4 +54,65 @@ public class BizMaterialController extends BaseController {
public AjaxResult remove(@PathVariable Long[] materialIds) {
return toAjax(service.deleteBizMaterialByIds(materialIds));
}
// ========== 物料详情页接口 ==========
@PreAuthorize("@ss.hasPermi('bid:material:detail')")
@GetMapping("/{id}/supplier-quotes")
public AjaxResult supplierQuotes(@PathVariable Long id) {
return success(service.selectSupplierQuotesByMaterialId(id));
}
@PreAuthorize("@ss.hasPermi('bid:material:detail')")
@GetMapping("/{id}/client-quotes")
public AjaxResult clientQuotes(@PathVariable Long id) {
return success(service.selectClientQuotesByMaterialId(id));
}
@GetMapping("/manufacturer/list")
public AjaxResult manufacturerList() {
return success(service.selectManufacturerList());
}
// ========== 物料横向对比 ==========
@PreAuthorize("@ss.hasPermi('bid:material:list')")
@PostMapping("/compare")
public AjaxResult compare(@RequestBody List<Long> materialIds) {
if (materialIds == null || materialIds.isEmpty()) {
return error("请至少选择两个物料进行对比");
}
if (materialIds.size() > 10) {
return error("一次最多对比10个物料");
}
return success(service.compareMaterials(materialIds));
}
// ========== 同类型物料横向对比 ==========
@PreAuthorize("@ss.hasPermi('bid:material:list')")
@GetMapping("/selectable-for-comparison")
public AjaxResult getSelectableMaterials() {
return success(service.selectMaterialsWithSupplierQuotes());
}
@PreAuthorize("@ss.hasPermi('bid:material:list')")
@GetMapping("/same-name/{materialName}")
public AjaxResult getSameNameMaterials(@PathVariable String materialName,
@RequestParam(required = false) Long excludeId) {
List<BizMaterial> list = service.selectMaterialsByExactName(materialName, excludeId);
return success(list);
}
@PreAuthorize("@ss.hasPermi('bid:material:list')")
@PostMapping("/quote-comparison")
public AjaxResult getQuoteComparison(@RequestBody List<Long> materialIds) {
if (materialIds == null || materialIds.isEmpty()) {
return error("请至少选择一个物料");
}
if (materialIds.size() > 10) {
return error("一次最多对比10个物料");
}
return success(service.selectSupplierQuoteComparison(materialIds));
}
}

View File

@@ -8,7 +8,7 @@ spring:
master:
url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: password
password: 135827
# 从库数据源
slave:
# 从数据源开关/默认关闭

View File

@@ -9,7 +9,6 @@ public class BizMaterial extends BaseEntity {
private String materialCode;
private String materialName;
private String spec;
private String modelNo;
private String unit;
private String brand;
private String description;
@@ -17,6 +16,12 @@ public class BizMaterial extends BaseEntity {
// search helper
private String categoryName;
// 新增字段
private String performanceParams;
private String material;
private String purpose;
private String imageUrl;
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public Long getTenantId() { return tenantId; }
@@ -29,8 +34,6 @@ public class BizMaterial extends BaseEntity {
public void setMaterialName(String materialName) { this.materialName = materialName; }
public String getSpec() { return spec; }
public void setSpec(String spec) { this.spec = spec; }
public String getModelNo() { return modelNo; }
public void setModelNo(String modelNo) { this.modelNo = modelNo; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public String getBrand() { return brand; }
@@ -41,4 +44,12 @@ public class BizMaterial extends BaseEntity {
public void setStatus(String status) { this.status = status; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public String getPerformanceParams() { return performanceParams; }
public void setPerformanceParams(String performanceParams) { this.performanceParams = performanceParams; }
public String getMaterial() { return material; }
public void setMaterial(String material) { this.material = material; }
public String getPurpose() { return purpose; }
public void setPurpose(String purpose) { this.purpose = purpose; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
}

View File

@@ -1,7 +1,9 @@
package com.ruoyi.system.mapper.bid;
import com.ruoyi.system.domain.bid.BizMaterial;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
public interface BizMaterialMapper {
List<BizMaterial> selectBizMaterialList(BizMaterial query);
@@ -10,4 +12,21 @@ public interface BizMaterialMapper {
int updateBizMaterial(BizMaterial record);
int deleteBizMaterialById(Long id);
int deleteBizMaterialByIds(Long[] ids);
// 物料详情页
List<Map<String, Object>> selectSupplierQuotesByMaterialId(Long materialId);
// 批量物料对比
List<BizMaterial> selectBizMaterialByIds(List<Long> materialIds);
List<Map<String, Object>> selectSupplierPriceSummaryByMaterialIds(List<Long> materialIds);
List<Map<String, Object>> selectBestSupplierOfferByMaterialIds(List<Long> materialIds);
List<Map<String, Object>> selectClientQuotesByMaterialId(Long materialId);
List<String> selectManufacturerList();
// 同类型物料横向对比
List<BizMaterial> selectMaterialsWithSupplierQuotes();
List<Map<String, Object>> selectSupplierQuoteComparison(List<Long> materialIds);
// 根据物料名称精确匹配(同名称不同规格/品牌对比)
List<BizMaterial> selectMaterialsByExactName(@Param("materialName") String materialName, @Param("excludeId") Long excludeId);
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.system.service.bid;
import com.ruoyi.system.domain.bid.BizMaterial;
import java.util.List;
import java.util.Map;
public interface IBizMaterialService {
List<BizMaterial> selectBizMaterialList(BizMaterial query);
@@ -10,4 +11,18 @@ public interface IBizMaterialService {
int updateBizMaterial(BizMaterial record);
int deleteBizMaterialById(Long id);
int deleteBizMaterialByIds(Long[] ids);
List<Map<String, Object>> selectSupplierQuotesByMaterialId(Long materialId);
List<Map<String, Object>> selectClientQuotesByMaterialId(Long materialId);
List<String> selectManufacturerList();
// 批量物料对比
Map<String, Object> compareMaterials(List<Long> materialIds);
// 同类型物料横向对比
List<BizMaterial> selectMaterialsWithSupplierQuotes();
List<Map<String, Object>> selectSupplierQuoteComparison(List<Long> materialIds);
// 根据物料名称精确匹配(同名称不同规格/品牌对比)
List<BizMaterial> selectMaterialsByExactName(String materialName, Long excludeId);
}

View File

@@ -5,7 +5,8 @@ import com.ruoyi.system.mapper.bid.BizMaterialMapper;
import com.ruoyi.system.service.bid.IBizMaterialService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class BizMaterialServiceImpl implements IBizMaterialService {
@@ -41,4 +42,70 @@ public class BizMaterialServiceImpl implements IBizMaterialService {
public int deleteBizMaterialByIds(Long[] ids) {
return mapper.deleteBizMaterialByIds(ids);
}
@Override
public List<Map<String, Object>> selectSupplierQuotesByMaterialId(Long materialId) {
return mapper.selectSupplierQuotesByMaterialId(materialId);
}
@Override
public List<Map<String, Object>> selectClientQuotesByMaterialId(Long materialId) {
return mapper.selectClientQuotesByMaterialId(materialId);
}
@Override
public List<String> selectManufacturerList() {
return mapper.selectManufacturerList();
}
@Override
public Map<String, Object> compareMaterials(List<Long> materialIds) {
if (materialIds == null || materialIds.isEmpty()) {
return Collections.emptyMap();
}
// 1. 获取物料基本信息
List<BizMaterial> materials = mapper.selectBizMaterialByIds(materialIds);
// 2. 获取供应商报价汇总
List<Map<String, Object>> priceSummaries = mapper.selectSupplierPriceSummaryByMaterialIds(materialIds);
Map<Long, Map<String, Object>> priceMap = new HashMap<>();
for (Map<String, Object> row : priceSummaries) {
Long mid = ((Number) row.get("material_id")).longValue();
priceMap.put(mid, row);
}
// 3. 获取最优报价详情
List<Map<String, Object>> bestOffers = mapper.selectBestSupplierOfferByMaterialIds(materialIds);
Map<Long, Map<String, Object>> bestOfferMap = new HashMap<>();
for (Map<String, Object> row : bestOffers) {
Long mid = ((Number) row.get("material_id")).longValue();
bestOfferMap.put(mid, row);
}
Map<String, Object> result = new HashMap<>();
result.put("materials", materials);
result.put("priceMap", priceMap);
result.put("bestOfferMap", bestOfferMap);
return result;
}
@Override
public List<BizMaterial> selectMaterialsWithSupplierQuotes() {
return mapper.selectMaterialsWithSupplierQuotes();
}
@Override
public List<Map<String, Object>> selectSupplierQuoteComparison(List<Long> materialIds) {
if (materialIds == null || materialIds.isEmpty()) {
return Collections.emptyList();
}
return mapper.selectSupplierQuoteComparison(materialIds);
}
@Override
public List<BizMaterial> selectMaterialsByExactName(String materialName, Long excludeId) {
return mapper.selectMaterialsByExactName(materialName, excludeId);
}
}

View File

@@ -9,7 +9,6 @@
<result property="materialCode" column="material_code"/>
<result property="materialName" column="material_name"/>
<result property="spec" column="spec"/>
<result property="modelNo" column="model_no"/>
<result property="unit" column="unit"/>
<result property="brand" column="brand"/>
<result property="description" column="description"/>
@@ -18,27 +17,40 @@
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="performanceParams" column="performance_params"/>
<result property="material" column="material"/>
<result property="purpose" column="purpose"/>
<result property="imageUrl" column="image_url"/>
</resultMap>
<select id="selectBizMaterialList" resultMap="BaseRM">
SELECT m.* FROM biz_material m
SELECT m.*, c.category_name AS category_name
FROM biz_material m
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
<where>
<if test="tenantId != null"> AND m.tenant_id = #{tenantId}</if>
<if test="categoryId != null"> AND m.category_id = #{categoryId}</if>
<if test="materialCode != null and materialCode != &apos;&apos;"> AND m.material_code LIKE CONCAT(&apos;%&apos;,#{materialCode},&apos;%&apos;)</if>
<if test="materialName != null and materialName != &apos;&apos;"> AND m.material_name LIKE CONCAT(&apos;%&apos;,#{materialName},&apos;%&apos;)</if>
<if test="status != null and status != &apos;&apos;"> AND m.status = #{status}</if>
<if test="materialCode != null and materialCode != ''"> AND m.material_code LIKE CONCAT('%',#{materialCode},'%')</if>
<if test="materialName != null and materialName != ''"> AND m.material_name LIKE CONCAT('%',#{materialName},'%')</if>
<if test="brand != null and brand != ''"> AND m.brand LIKE CONCAT('%',#{brand},'%')</if>
<if test="spec != null and spec != ''"> AND m.spec LIKE CONCAT('%',#{spec},'%')</if>
<if test="status != null and status != ''"> AND m.status = #{status}</if>
</where>
ORDER BY m.material_id DESC
</select>
<select id="selectBizMaterialById" resultMap="BaseRM">
SELECT * FROM biz_material WHERE material_id=#{id}
SELECT m.*, c.category_name AS category_name
FROM biz_material m
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
WHERE m.material_id=#{id}
</select>
<insert id="insertBizMaterial" useGeneratedKeys="true" keyProperty="materialId">
INSERT INTO biz_material(tenant_id,category_id,material_code,material_name,spec,model_no,unit,brand,description,status,create_by,create_time)
VALUES(#{tenantId},#{categoryId},#{materialCode},#{materialName},#{spec},#{modelNo},#{unit},#{brand},#{description},#{status},#{createBy},NOW())
INSERT INTO biz_material(tenant_id,category_id,material_code,material_name,spec,unit,brand,
description,status,performance_params,material,purpose,image_url,create_by,create_time)
VALUES(#{tenantId},#{categoryId},#{materialCode},#{materialName},#{spec},#{unit},#{brand},
#{description},#{status},#{performanceParams},#{material},#{purpose},#{imageUrl},#{createBy},NOW())
</insert>
<update id="updateBizMaterial">
@@ -47,12 +59,15 @@
<if test="materialName != null">material_name=#{materialName},</if>
<if test="materialCode != null">material_code=#{materialCode},</if>
<if test="spec != null">spec=#{spec},</if>
<if test="modelNo != null">model_no=#{modelNo},</if>
<if test="unit != null">unit=#{unit},</if>
<if test="brand != null">brand=#{brand},</if>
<if test="description != null">description=#{description},</if>
<if test="categoryId != null">category_id=#{categoryId},</if>
<if test="status != null">status=#{status},</if>
<if test="performanceParams != null">performance_params=#{performanceParams},</if>
<if test="material != null">material=#{material},</if>
<if test="purpose != null">purpose=#{purpose},</if>
<if test="imageUrl != null">image_url=#{imageUrl},</if>
update_by=#{updateBy}, update_time=NOW()
</set>
WHERE material_id=#{materialId}
@@ -63,4 +78,129 @@
DELETE FROM biz_material WHERE material_id IN
<foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
<!-- 供应商报价历史:通过 rfq_item 关联物料 -->
<select id="selectSupplierQuotesByMaterialId" resultType="java.util.Map">
SELECT qi.item_id, qi.unit_price, qi.total_price, qi.delivery_days,
q.quotation_id, q.quote_no, q.submit_time, q.status AS quote_status,
s.supplier_id, s.supplier_name
FROM biz_quotation_item qi
JOIN biz_quotation q ON qi.quotation_id = q.quotation_id
JOIN biz_supplier s ON q.supplier_id = s.supplier_id
JOIN biz_rfq_item ri ON qi.rfq_item_id = ri.item_id
WHERE ri.material_id = #{materialId}
ORDER BY q.submit_time DESC
</select>
<!-- 甲方报价历史:通过物料名称关联 -->
<select id="selectClientQuotesByMaterialId" resultType="java.util.Map">
SELECT cqi.item_id, cqi.spec, cqi.model_no, cqi.unit, cqi.quantity,
cqi.cost_price, cqi.unit_price, cqi.total_price, cqi.delivery_days,
cq.quote_id, cq.quote_no, cq.client_name, cq.status AS quote_status, cq.create_time
FROM biz_client_quote_item cqi
JOIN biz_client_quote cq ON cqi.quote_id = cq.quote_id
WHERE cqi.material_name = (SELECT material_name FROM biz_material WHERE material_id = #{materialId})
ORDER BY cq.create_time DESC
</select>
<!-- ========== 批量物料对比 ========== -->
<select id="selectBizMaterialByIds" resultMap="BaseRM">
SELECT m.*, c.category_name AS category_name
FROM biz_material m
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
WHERE m.material_id IN
<foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY m.material_id
</select>
<!-- 按物料分组统计供应商报价汇总 -->
<select id="selectSupplierPriceSummaryByMaterialIds" resultType="java.util.Map">
SELECT ri.material_id,
MIN(qi.unit_price) AS min_price,
MAX(qi.unit_price) AS max_price,
ROUND(AVG(qi.unit_price), 2) AS avg_price,
COUNT(DISTINCT q.supplier_id) AS supplier_count
FROM biz_quotation_item qi
JOIN biz_quotation q ON qi.quotation_id = q.quotation_id
JOIN biz_rfq_item ri ON qi.rfq_item_id = ri.item_id
WHERE ri.material_id IN
<foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY ri.material_id
</select>
<!-- 每个物料最低价对应的供应商详情 -->
<select id="selectBestSupplierOfferByMaterialIds" resultType="java.util.Map">
SELECT t.material_id, t.unit_price, t.delivery_days, s.supplier_name, s.contact, s.phone
FROM (
SELECT ri.material_id, qi.unit_price, qi.delivery_days, qi.quotation_id,
ROW_NUMBER() OVER (PARTITION BY ri.material_id ORDER BY qi.unit_price ASC, qi.delivery_days ASC) AS rn
FROM biz_quotation_item qi
JOIN biz_quotation q ON qi.quotation_id = q.quotation_id
JOIN biz_rfq_item ri ON qi.rfq_item_id = ri.item_id
WHERE ri.material_id IN
<foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach>
AND qi.unit_price IS NOT NULL
) t
JOIN biz_quotation q ON t.quotation_id = q.quotation_id
JOIN biz_supplier s ON q.supplier_id = s.supplier_id
WHERE t.rn = 1
</select>
<!-- 获取厂家/品牌列表(去重) -->
<select id="selectManufacturerList" resultType="java.lang.String">
SELECT DISTINCT brand FROM biz_material
WHERE brand IS NOT NULL AND brand != ''
ORDER BY brand
</select>
<!-- 根据物料名称精确匹配查询(用于同名称不同规格/品牌对比) -->
<select id="selectMaterialsByExactName" resultMap="BaseRM">
SELECT m.*, c.category_name AS category_name
FROM biz_material m
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
WHERE m.material_name = #{materialName}
AND m.material_id != #{excludeId}
ORDER BY m.material_id DESC
</select>
<!-- ========== 同类型物料横向对比 ========== -->
<!-- 获取所有有供应商报价记录的物料列表(用于对比选择框) -->
<select id="selectMaterialsWithSupplierQuotes" resultMap="BaseRM">
SELECT DISTINCT m.*, c.category_name AS category_name
FROM biz_material m
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
WHERE EXISTS (
SELECT 1 FROM biz_quotation_item qi
JOIN biz_quotation q ON qi.quotation_id = q.quotation_id
JOIN biz_rfq_item ri ON qi.rfq_item_id = ri.item_id
WHERE ri.material_id = m.material_id
)
ORDER BY m.material_name, m.material_id DESC
</select>
<!-- 根据物料ID列表获取各供应商的详细报价明细用于对比展示 -->
<select id="selectSupplierQuoteComparison" resultType="java.util.Map">
SELECT
ri.material_id,
qi.unit_price,
qi.total_price,
qi.delivery_days,
q.quotation_id,
q.quote_no,
q.submit_time,
q.status AS quote_status,
s.supplier_id,
s.supplier_name,
s.contact AS supplier_contact,
s.phone AS supplier_phone
FROM biz_quotation_item qi
JOIN biz_quotation q ON qi.quotation_id = q.quotation_id
JOIN biz_supplier s ON q.supplier_id = s.supplier_id
JOIN biz_rfq_item ri ON qi.rfq_item_id = ri.item_id
WHERE ri.material_id IN
<foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY ri.material_id, qi.unit_price ASC, q.submit_time DESC
</select>
</mapper>

View File

@@ -1,7 +1,25 @@
import request from '@/utils/request'
const baseUrl = '/bid/material'
export const listMaterial = (params) => request({ url: baseUrl + '/list', method: 'get', params })
export const getMaterial = (id) => request({ url: baseUrl + '/' + id, method: 'get' })
export const addMaterial = (data) => request({ url: baseUrl, method: 'post', data })
export const updateMaterial = (data) => request({ url: baseUrl, method: 'put', data })
export const delMaterial = (ids) => request({ url: baseUrl + '/' + ids, method: 'delete' })
// 物料详情页
export const getSupplierQuotes = (id) => request({ url: baseUrl + '/' + id + '/supplier-quotes', method: 'get' })
export const getClientQuotes = (id) => request({ url: baseUrl + '/' + id + '/client-quotes', method: 'get' })
export const listManufacturer = () => request({ url: baseUrl + '/manufacturer/list', method: 'get' })
export const compareMaterials = (data) => request({ url: baseUrl + '/compare', method: 'post', data })
// 同类型物料横向对比
export const getSelectableMaterials = () => request({ url: baseUrl + '/selectable-for-comparison', method: 'get' })
export const getQuoteComparison = (data) => request({ url: baseUrl + '/quote-comparison', method: 'post', data })
// 同名称不同规格/品牌对比
export const getSameNameMaterials = (materialName, excludeId) => request({
url: baseUrl + '/same-name/' + encodeURIComponent(materialName),
method: 'get',
params: excludeId ? { excludeId } : undefined
})

View File

@@ -176,3 +176,28 @@ aside {
margin-bottom: 10px;
}
}
/* ==========================================
表格展示优化 - 表头略大于数据,字体优化
========================================== */
.el-table thead th {
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.el-table__body td {
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
color: #606266;
}
.el-table__body .el-table__row {
height: 44px;
}
.el-table--border th,
.el-table--border td {
padding: 6px 8px;
}

View File

@@ -98,6 +98,20 @@ export const constantRoutes = [
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{
path: '/bid/material/detail',
component: Layout,
hidden: true,
permissions: ['bid:material:detail'],
children: [
{
path: '',
component: () => import('@/views/bid/material/detail'),
name: 'MaterialDetail',
meta: { title: '物料详情', activeMenu: '/bid/material' }
}
]
},
{
path: '/system/user-auth',
component: Layout,

View File

@@ -74,7 +74,7 @@ export default {
},
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, rfqNo: null, rfqTitle: null }; this.getList(); },
goCompare(row) {
this.$router.push({ path: "/procurement/comparison/detail", query: { rfqId: row.rfqId } });
this.$router.push({ path: "/bid/comparison/detail", query: { rfqId: row.rfqId } });
},
goRfqDetail(row) {
this.$router.push({ path: "/procurement/rfq/detail", query: { rfqId: row.rfqId } });

View File

@@ -0,0 +1,330 @@
<template>
<div class="basic-info-tab">
<!-- 操作栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-if="!isEditing"
type="primary"
icon="el-icon-edit"
size="mini"
@click="handleEdit">编辑</el-button>
<template v-else>
<el-button
type="success"
icon="el-icon-check"
size="mini"
@click="handleSave">保存</el-button>
<el-button
icon="el-icon-close"
size="mini"
@click="handleCancel">取消</el-button>
</template>
</el-col>
</el-row>
<!-- 基础信息表格 -->
<div class="section-title">基础信息</div>
<el-table
:data="basicInfoData"
border
size="small"
:show-header="false"
class="info-table">
<el-table-column width="120" align="right" class-name="label-column">
<template slot-scope="scope">
<span class="info-label">{{ scope.row.label }}</span>
</template>
</el-table-column>
<el-table-column>
<template slot-scope="scope">
<el-input
v-if="isEditing"
v-model="scope.row.value"
size="small"
:placeholder="'请输入' + scope.row.label" />
<span v-else class="info-value">{{ scope.row.value || '-' }}</span>
</template>
</el-table-column>
<el-table-column width="120" align="right" class-name="label-column">
<template slot-scope="scope">
<span class="info-label">{{ scope.row.label2 }}</span>
</template>
</el-table-column>
<el-table-column>
<template slot-scope="scope">
<el-input
v-if="isEditing"
v-model="scope.row.value2"
size="small"
:placeholder="'请输入' + scope.row.label2" />
<span v-else class="info-value">{{ scope.row.value2 || '-' }}</span>
</template>
</el-table-column>
</el-table>
<!-- 性能参数表格 -->
<div class="section-title">
<span>性能参数</span>
<el-button
v-if="isEditing"
type="primary"
size="mini"
icon="el-icon-plus"
@click="addParam">添加参数</el-button>
</div>
<el-table
:data="perfParams"
border
size="small"
class="param-table">
<el-table-column label="参数名" width="200">
<template slot-scope="scope">
<el-input
v-if="isEditing"
v-model="scope.row.name"
size="small"
placeholder="如:输出电压" />
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="参数值" width="200">
<template slot-scope="scope">
<el-input
v-if="isEditing"
v-model="scope.row.value"
size="small"
placeholder="如24VDC" />
<span v-else>{{ scope.row.value }}</span>
</template>
</el-table-column>
<el-table-column label="单位" width="150">
<template slot-scope="scope">
<el-input
v-if="isEditing"
v-model="scope.row.unit"
size="small"
placeholder="如V" />
<span v-else>{{ scope.row.unit }}</span>
</template>
</el-table-column>
<el-table-column v-if="isEditing" label="操作" width="120" align="center">
<template slot-scope="scope">
<el-button
type="text"
style="color: #f56c6c"
@click="deleteParam(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!perfParams || perfParams.length === 0"
description="暂无性能参数"
:image-size="80" />
<!-- 描述表格 -->
<div class="section-title">描述</div>
<el-table
:data="[{label: '描述', value: description}]"
border
size="small"
:show-header="false"
class="desc-table">
<el-table-column width="120" align="right" class-name="label-column">
<template>
<span class="info-label">描述</span>
</template>
</el-table-column>
<el-table-column>
<template>
<el-input
v-if="isEditing"
v-model="description"
type="textarea"
:rows="4"
placeholder="请输入物料描述" />
<div v-else class="description-content">{{ description || '暂无描述' }}</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { updateMaterial } from "@/api/bid/material";
export default {
name: "BasicInfoTab",
props: {
material: { type: Object, default: () => ({}) }
},
data() {
return {
isEditing: false,
basicInfoData: [],
perfParams: [],
description: ''
};
},
watch: {
material: {
immediate: true,
handler(val) {
if (val) {
this.initData();
}
}
}
},
methods: {
// 初始化数据
initData() {
// 基础信息数据(两列布局)
this.basicInfoData = [
{
label: '物料编码', value: this.material.materialCode || '',
label2: '所属分类', value2: this.material.categoryName || ''
},
{
label: '物料名称', value: this.material.materialName || '',
label2: '厂家/品牌', value2: this.material.brand || ''
},
{
label: '规格型号', value: this.material.spec || '',
label2: '材质', value2: this.material.material || ''
},
{
label: '型号', value: this.material.modelNo || '',
label2: '用途', value2: this.material.purpose || ''
},
{
label: '单位', value: this.material.unit || '',
label2: '备注', value2: this.material.remark || ''
}
];
// 性能参数数据
this.perfParams = this.material.perfArray && this.material.perfArray.length > 0
? JSON.parse(JSON.stringify(this.material.perfArray))
: [];
// 描述
this.description = this.material.description || '';
},
// 进入编辑模式
handleEdit() {
this.isEditing = true;
},
// 保存(实时保存,无需刷新)
async handleSave() {
try {
// 构建保存的数据
const saveData = {
materialId: this.material.materialId,
materialCode: this.basicInfoData[0].value,
materialName: this.basicInfoData[1].value,
spec: this.basicInfoData[2].value,
modelNo: this.basicInfoData[3].value,
unit: this.basicInfoData[4].value,
brand: this.basicInfoData[1].value2,
material: this.basicInfoData[2].value2,
purpose: this.basicInfoData[3].value2,
remark: this.basicInfoData[4].value2,
performanceParams: JSON.stringify(this.perfParams),
description: this.description
};
// 调用API保存
await updateMaterial(saveData);
this.$message.success("保存成功");
this.isEditing = false;
// 更新本地数据
this.$emit('update:material', { ...this.material, ...saveData, perfArray: this.perfParams });
} catch (error) {
this.$message.error("保存失败:" + error.message);
}
},
// 取消编辑
handleCancel() {
this.isEditing = false;
this.initData(); // 恢复原始数据
},
// 添加性能参数
addParam() {
this.perfParams.push({ name: '', value: '', unit: '' });
},
// 删除性能参数
deleteParam(index) {
this.perfParams.splice(index, 1);
}
}
};
</script>
<style scoped>
.basic-info-tab {
padding: 10px;
}
.mb8 {
margin-bottom: 8px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 20px 0 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.info-table >>> .label-column {
background-color: #f5f7fa;
}
.info-label {
color: #606266;
font-weight: 500;
}
.info-value {
color: #303133;
}
.param-table {
margin-bottom: 10px;
}
.desc-table {
margin-bottom: 20px;
}
.description-content {
color: #606266;
line-height: 1.8;
min-height: 60px;
padding: 8px 0;
}
/* 表格样式优化 */
>>> .el-table .cell {
padding: 8px 10px;
}
>>> .el-input__inner {
padding: 0 8px;
}
>>> .el-textarea__inner {
min-height: 80px !important;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div>
<div style="margin-bottom:10px; text-align:right">
<el-button size="mini" icon="el-icon-download" @click="exportExcel">导出Excel</el-button>
</div>
<el-table :data="list" v-loading="loading" border size="small">
<el-table-column label="报价日期" prop="create_time" width="160" />
<el-table-column label="甲方名称" prop="client_name" width="160" />
<el-table-column label="成本价(元)" prop="cost_price" width="120">
<template slot-scope="scope">¥{{ scope.row.cost_price }}</template>
</el-table-column>
<el-table-column label="单价(元)" prop="unit_price" width="120">
<template slot-scope="scope">¥{{ scope.row.unit_price }}</template>
</el-table-column>
<el-table-column label="成交价(元)" prop="total_price" width="120">
<template slot-scope="scope">¥{{ scope.row.total_price }}</template>
</el-table-column>
<el-table-column label="报价单号" prop="quote_no" width="150" />
<el-table-column label="状态" prop="quote_status" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.quote_status === 'accepted' ? 'success' : 'danger'" size="small">
{{ scope.row.quote_status === 'accepted' ? '已接受' : '已拒绝' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无甲方报价" :image-size="80" />
</div>
</template>
<script>
import { getClientQuotes } from "@/api/bid/material";
export default {
name: "ClientQuoteTab",
props: { materialId: [Number, String] },
data() { return { loading: false, list: [] }; },
created() { this.loadData(); },
methods: {
loadData() {
if (!this.materialId) return;
this.loading = true;
getClientQuotes(this.materialId).then(res => {
this.list = res.data || [];
this.loading = false;
}).catch(() => { this.loading = false; });
},
exportExcel() {
this.$message.info('导出功能开发中');
}
}
};
</script>

View File

@@ -0,0 +1,719 @@
<template>
<div class="compare-section">
<!-- 顶部控制栏 -->
<div class="compare-bar">
<div class="compare-bar-left">
<span class="compare-section-title">同品类物料横向对比</span>
<el-tag v-if="selectedMaterials.length" size="small" type="primary" style="margin-left:8px">
{{ selectedMaterials.length }} 个物料
</el-tag>
</div>
<div class="compare-bar-right">
<span class="select-hint">选择需要对比的物料</span>
<el-select
v-model="targetIds"
multiple
filterable
clearable
placeholder="同名称不同规格/品牌的物料..."
size="small"
style="width:400px"
:loading="loadingMaterials"
@change="handleSelectionChange">
<el-option
v-for="m in sameNameMaterials"
:key="m.materialId"
:label="buildOptionLabel(m)"
:value="m.materialId" />
</el-select>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loadingMaterials || loadingQuote" class="compare-status">
<i class="el-icon-loading"></i> 正在加载数据请稍候
</div>
<!-- 错误状态 -->
<div v-else-if="loadError" class="compare-error">
<i class="el-icon-warning-outline"></i>
<span class="error-text">{{ loadError }}</span>
<el-button size="small" type="primary" icon="el-icon-refresh" @click="retryLoad">重新加载</el-button>
</div>
<!-- 数据内容卡片式布局 -->
<div v-else-if="selectedMaterials.length" class="compare-content">
<div class="quote-comparison-grid">
<div
v-for="mat in selectedMaterials"
:key="mat.materialId"
class="material-quote-card">
<!-- 卡片头部物料名称 + 规格/品牌 -->
<div class="mat-card-header">
<div class="mat-title-group">
<span class="mat-name">{{ mat.materialName }}</span>
<span class="mat-subtitle">{{ mat.spec || '—' }} / {{ mat.brand || '—' }}</span>
</div>
<el-tag v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length"
size="mini" type="success" effect="plain">
{{ quoteMap[mat.materialId].length }} 家报价
</el-tag>
<el-tag v-else size="mini" type="info" effect="plain">暂无报价</el-tag>
</div>
<!-- 基础信息区 -->
<div class="mat-basic-body">
<div class="basic-info-grid">
<div class="basic-info-item">
<span class="basic-label">规格型号</span>
<span class="basic-value">{{ mat.spec || '—' }}</span>
</div>
<div class="basic-info-item">
<span class="basic-label">厂家/品牌</span>
<span class="basic-value">{{ mat.brand || '—' }}</span>
</div>
<div class="basic-info-item">
<span class="basic-label">材质</span>
<span class="basic-value">{{ mat.material || '—' }}</span>
</div>
<div class="basic-info-item">
<span class="basic-label">单位</span>
<span class="basic-value">{{ mat.unit || '—' }}</span>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider">
<span class="divider-line"></span>
<span class="divider-text">
<i class="el-icon-s-data"></i> 供应商报价明细
</span>
<span class="divider-line"></span>
</div>
<!-- 供应商报价区 -->
<div class="mat-quote-section">
<div v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length" class="quote-list">
<div class="quote-header-row">
<span class="qh-col supplier-col">供应商</span>
<span class="qh-col price-col">单价()</span>
<span class="qh-col delivery-col">交期()</span>
<span class="qh-col quote-no-col">报价单号</span>
<span class="qh-col date-col">报价日期</span>
</div>
<div
v-for="(quote, qIdx) in quoteMap[mat.materialId]"
:key="qIdx"
class="quote-row"
:class="{ 'is-lowest': isLowestPrice(mat.materialId, qIdx) }">
<div class="qr-col supplier-col">
<span class="supplier-name" :title="quote.supplier_name">{{ quote.supplier_name }}</span>
<span v-if="quote.supplier_contact || quote.supplier_phone" class="supplier-contact">
{{ quote.supplier_contact || '' }} {{ quote.supplier_phone || '' }}
</span>
</div>
<div class="qr-col price-col">
<span class="price-value">¥{{ formatPrice(quote.unit_price) }}</span>
<span v-if="isLowestPrice(mat.materialId, qIdx)" class="lowest-tag">最低</span>
</div>
<div class="qr-col delivery-col">{{ quote.delivery_days || '—' }}</div>
<div class="qr-col quote-no-col" :title="quote.quote_no">{{ quote.quote_no }}</div>
<div class="qr-col date-col">{{ formatDate(quote.submit_time) }}</div>
</div>
</div>
<div v-else class="quote-empty">
<i class="el-icon-info"></i>
<span>暂无供应商报价数据</span>
</div>
</div>
<!-- 汇总统计仅在有报价时显示 -->
<div v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length" class="quote-summary-bar">
<div class="summary-item">
<span class="summary-label">最低价</span>
<span class="summary-value price-lowest">¥{{ formatPrice(getMinPrice(mat.materialId)) }}</span>
</div>
<div class="summary-item">
<span class="summary-label">最高价</span>
<span class="summary-value">¥{{ formatPrice(getMaxPrice(mat.materialId)) }}</span>
</div>
<div class="summary-item">
<span class="summary-label">均价</span>
<span class="summary-value">¥{{ formatPrice(getAvgPrice(mat.materialId)) }}</span>
</div>
<div class="summary-item">
<span class="summary-label">最高价差</span>
<span class="summary-value price-diff">{{ getPriceDiff(mat.materialId) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="cmp-hint">
<i class="el-icon-info"></i>
<span>暂无同名称不同规格/品牌的物料可对比</span>
</div>
</div>
</template>
<script>
import { getSameNameMaterials, getQuoteComparison } from "@/api/bid/material";
export default {
name: "CompareSection",
props: {
materialId: { type: [Number, String], required: true },
material: { type: Object, default: () => ({}) }
},
data() {
return {
loadingMaterials: false,
loadingQuote: false,
loadError: null,
sameNameMaterials: [],
targetIds: [],
quoteMap: {},
_loadingLock: false
};
},
computed: {
currentMaterial() {
return this.material || {};
},
selectedMaterials() {
const others = this.sameNameMaterials.filter(m => this.targetIds.includes(m.materialId));
return [this.currentMaterial, ...others];
}
},
watch: {
'material.materialName': {
immediate: true,
handler(val) {
if (val) this.initLoad();
}
}
},
methods: {
buildOptionLabel(m) {
const parts = [m.materialName];
if (m.spec) parts.push(m.spec);
if (m.brand) parts.push(m.brand);
return parts.join(' / ');
},
async initLoad() {
this.loadError = null;
await this.loadSameNameMaterials();
if (this.sameNameMaterials.length) {
const allIds = this.sameNameMaterials.map(m => m.materialId);
this._loadingLock = true;
await this.loadQuoteComparison(allIds);
this.targetIds = allIds;
this._loadingLock = false;
}
},
async loadSameNameMaterials() {
this.loadingMaterials = true;
try {
const res = await getSameNameMaterials(this.currentMaterial.materialName, Number(this.materialId));
this.sameNameMaterials = res.data || [];
} catch (err) {
this.sameNameMaterials = [];
this.loadError = '加载物料列表失败:' + (err.message || '网络异常');
} finally {
this.loadingMaterials = false;
}
},
async handleSelectionChange(selectedIds) {
if (this._loadingLock) return;
this.loadError = null;
if (selectedIds.length) {
await this.loadQuoteComparison(selectedIds);
} else {
this.quoteMap = {};
}
},
async loadQuoteComparison(materialIds) {
if (!materialIds || !materialIds.length) return;
this.loadingQuote = true;
this.loadError = null;
try {
const ids = [Number(this.materialId), ...materialIds];
const res = await getQuoteComparison(ids);
const data = res.data || [];
const map = {};
data.forEach(item => {
const mid = item.material_id;
if (!map[mid]) map[mid] = [];
map[mid].push(item);
});
Object.keys(map).forEach(mid => {
map[mid].sort((a, b) => {
const pa = Number(a.unit_price) || 0;
const pb = Number(b.unit_price) || 0;
return pa - pb;
});
});
this.quoteMap = map;
} catch (err) {
this.loadError = '加载报价明细失败:' + (err.message || '网络异常');
this.quoteMap = {};
} finally {
this.loadingQuote = false;
}
},
retryLoad() {
this.loadError = null;
this.initLoad();
},
formatPrice(val) {
if (val === null || val === undefined || val === '') return '—';
return Number(val).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 4
});
},
formatDate(val) {
if (!val) return '—';
const d = new Date(val);
if (isNaN(d.getTime())) return val;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
},
getMinPrice(materialId) {
const quotes = this.quoteMap[materialId];
if (!quotes || !quotes.length) return null;
return Math.min(...quotes.map(q => Number(q.unit_price) || 0));
},
getMaxPrice(materialId) {
const quotes = this.quoteMap[materialId];
if (!quotes || !quotes.length) return null;
return Math.max(...quotes.map(q => Number(q.unit_price) || 0));
},
getAvgPrice(materialId) {
const quotes = this.quoteMap[materialId];
if (!quotes || !quotes.length) return null;
const sum = quotes.reduce((acc, q) => acc + (Number(q.unit_price) || 0), 0);
return sum / quotes.length;
},
getPriceDiff(materialId) {
const min = this.getMinPrice(materialId);
const max = this.getMaxPrice(materialId);
if (min === null || max === null) return '—';
const diff = max - min;
if (diff === 0) return '无差异';
const pct = ((diff / min) * 100).toFixed(1);
return `¥${diff.toFixed(2)} (+${pct}%)`;
},
isLowestPrice(materialId, quoteIndex) {
const quotes = this.quoteMap[materialId];
if (!quotes || !quotes.length) return false;
return quoteIndex === 0;
}
}
};
</script>
<style scoped>
/* ========== 整体容器 ========== */
.compare-section {
margin-top: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
/* ========== 顶部控制栏 ========== */
.compare-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 14px 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
border-bottom: 1px solid #ebeef5;
}
.compare-bar-left { display: flex; align-items: center; }
.compare-bar-right {
display: flex;
align-items: center;
gap: 10px;
}
.select-hint {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
.compare-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
position: relative;
padding-left: 12px;
}
.compare-section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 14px;
background: linear-gradient(180deg, #409eff 0%, #2c3e50 100%);
border-radius: 2px;
}
/* ========== 状态提示 ========== */
.compare-status {
text-align: center;
padding: 50px 20px;
color: #909399;
font-size: 14px;
}
.compare-status i {
font-size: 22px;
margin-right: 8px;
animation: rotating 2s linear infinite;
}
@keyframes rotating {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.compare-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 50px 20px;
color: #f56c6c;
font-size: 14px;
}
.compare-error i {
font-size: 36px;
color: #f56c6c;
}
.compare-error .error-text {
color: #606266;
}
.cmp-hint {
text-align: center;
padding: 50px 20px;
color: #c0c4cc;
font-size: 13px;
}
.cmp-hint i { margin-right: 6px; }
/* ========== 内容区域 ========== */
.compare-content {
padding: 16px;
}
/* ========== 卡片网格 ========== */
.quote-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(480px, 1fr));
gap: 16px;
}
/* ========== 物料卡片 ========== */
.material-quote-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.3s ease;
display: flex;
flex-direction: column;
}
.material-quote-card:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
/* 卡片头部 */
.mat-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 14px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
border-bottom: 1px solid #ebeef5;
}
.mat-title-group {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.mat-name {
font-size: 15px;
font-weight: 700;
color: #1a2c4e;
line-height: 1.3;
}
.mat-subtitle {
font-size: 12px;
color: #606266;
}
/* 基础信息区 */
.mat-basic-body {
background: linear-gradient(135deg, #f8fafc 0%, #f0f4f8 100%);
padding: 12px 16px;
}
.basic-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
}
.basic-info-item {
display: flex;
align-items: center;
gap: 8px;
}
.basic-label {
font-size: 12px;
color: #909399;
flex-shrink: 0;
min-width: 56px;
}
.basic-value {
font-size: 13px;
color: #303133;
font-weight: 500;
word-break: break-all;
}
/* 分隔线 */
.section-divider {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #fff;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent 0%, #dcdfe6 50%, transparent 100%);
}
.divider-text {
font-size: 12px;
font-weight: 600;
color: #606266;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.divider-text i {
color: #409eff;
font-size: 14px;
}
/* 供应商报价区 */
.mat-quote-section {
padding: 0 8px 8px;
flex: 1;
}
.quote-list { font-size: 13px; }
.quote-header-row {
display: grid;
grid-template-columns: 1.6fr 1fr 0.7fr 1.3fr 0.9fr;
gap: 4px;
padding: 8px 10px;
background: #f5f7fa;
border-radius: 4px;
font-weight: 600;
color: #606266;
font-size: 12px;
margin-bottom: 4px;
}
.qh-col {
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qh-col:first-child { text-align: left; }
.quote-row {
display: grid;
grid-template-columns: 1.6fr 1fr 0.7fr 1.3fr 0.9fr;
gap: 4px;
padding: 10px;
margin: 0 4px;
border-bottom: 1px solid #f0f2f5;
align-items: center;
transition: background-color 0.2s ease;
border-radius: 4px;
}
.quote-row:hover { background: #f8f9fb; }
.quote-row.is-lowest {
background: linear-gradient(90deg, #f0f9eb 0%, #e8f5e0 100%);
border-left: 3px solid #67c23a;
}
.qr-col {
text-align: center;
overflow: hidden;
}
.qr-col:first-child { text-align: left; }
.supplier-name {
display: block;
font-weight: 600;
color: #303133;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.supplier-contact {
display: block;
font-size: 11px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.price-value {
font-weight: 700;
color: #f56c6c;
font-size: 14px;
}
.lowest-tag {
display: inline-block;
background: #67c23a;
color: #fff;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
font-weight: 500;
}
.qr-col.delivery-col,
.qr-col.quote-no-col,
.qr-col.date-col {
color: #606266;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 汇总统计 */
.quote-summary-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: #ebeef5;
border-top: 1px solid #ebeef5;
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: #fff;
gap: 4px;
}
.summary-label {
font-size: 11px;
color: #909399;
}
.summary-value {
font-size: 14px;
font-weight: 700;
color: #303133;
}
.price-lowest { color: #67c23a; }
.price-diff { color: #e6a23c; font-size: 13px; }
/* 空状态 */
.quote-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
color: #c0c4cc;
font-size: 13px;
gap: 8px;
}
.quote-empty i { font-size: 32px; }
/* ========== 响应式适配 ========== */
@media screen and (max-width: 1200px) {
.quote-comparison-grid {
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
}
}
@media screen and (max-width: 768px) {
.compare-bar {
flex-direction: column;
align-items: stretch;
}
.compare-bar-right {
width: 100%;
flex-direction: column;
align-items: stretch;
}
.compare-bar-right .el-select {
width: 100% !important;
}
.quote-comparison-grid {
grid-template-columns: 1fr;
}
.quote-header-row,
.quote-row {
grid-template-columns: 1.4fr 1fr 0.7fr 1fr 0.8fr;
gap: 2px;
padding: 8px 6px;
margin: 0;
}
.basic-info-grid {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 480px) {
.quote-header-row,
.quote-row {
grid-template-columns: 1.2fr 1fr 0.6fr;
font-size: 12px;
}
.quote-header-row .quote-no-col,
.quote-header-row .date-col,
.quote-row .quote-no-col,
.quote-row .date-col {
display: none;
}
.quote-summary-bar {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<el-card shadow="never">
<div class="detail-header">
<div class="header-left">
<h2 style="margin:0 0 8px 0">
{{ material.brand || '' }} {{ material.materialName || '' }} {{ material.spec || '' }}
</h2>
<el-tag size="small" v-if="material.categoryName">{{ material.categoryName }}</el-tag>
<el-tag size="small" type="success" v-if="material.material">{{ material.material }}</el-tag>
</div>
<div class="header-right">
<el-button icon="el-icon-arrow-left" size="small" @click="$emit('back')">返回列表</el-button>
</div>
</div>
<el-descriptions :column="4" border size="small" style="margin-top:12px">
<el-descriptions-item label="物料编码">{{ material.materialCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="厂家/品牌">{{ material.brand || '-' }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ material.spec || '-' }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ material.unit || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ material.material || '-' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ material.purpose || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ material.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<script>
export default {
name: "DetailHeader",
props: { material: { type: Object, default: () => ({}) } }
};
</script>
<style scoped>
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; }
.header-right { flex-shrink: 0; }
</style>

View File

@@ -0,0 +1,235 @@
<template>
<div class="supplier-quote-tab">
<!-- 操作栏 -->
<div class="tab-toolbar">
<span class="tab-title">供应商报价记录</span>
<el-button size="mini" icon="el-icon-download" @click="exportExcel">导出Excel</el-button>
</div>
<!-- 报价表格 -->
<el-table
:data="list"
v-loading="loading"
border
size="small"
class="quote-table"
:header-cell-style="headerStyle">
<el-table-column label="报价日期" width="120" align="center">
<template slot-scope="scope">
<span class="date-cell">{{ formatDate(scope.row.submit_time) }}</span>
</template>
</el-table-column>
<!-- 供应商信息完整一行展示 -->
<el-table-column label="供应商信息" min-width="280">
<template slot-scope="scope">
<div class="supplier-info">
<div class="supplier-name">{{ scope.row.supplier_name }}</div>
<div class="supplier-contact" v-if="scope.row.contact || scope.row.phone">
<span v-if="scope.row.contact">{{ scope.row.contact }}</span>
<span v-if="scope.row.phone" class="phone">{{ scope.row.phone }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="120" align="right">
<template slot-scope="scope">
<span class="price-cell">¥{{ formatPrice(scope.row.unit_price) }}</span>
</template>
</el-table-column>
<el-table-column label="总价(元)" width="120" align="right">
<template slot-scope="scope">
<span class="price-cell">¥{{ formatPrice(scope.row.total_price) }}</span>
</template>
</el-table-column>
<el-table-column label="交期" width="80" align="center">
<template slot-scope="scope">
<span class="days-cell">{{ scope.row.delivery_days }}</span>
</template>
</el-table-column>
<el-table-column label="报价单号" width="150" align="center">
<template slot-scope="scope">
<span class="quote-no">{{ scope.row.quote_no }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag
:type="getStatusType(scope.row.quote_status)"
size="small"
effect="dark"
class="status-tag">
{{ getStatusText(scope.row.quote_status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty v-if="!loading && !list.length" description="暂无供应商报价" :image-size="80">
<template #description>
<span class="empty-text">暂无供应商报价记录</span>
</template>
</el-empty>
</div>
</template>
<script>
import { getSupplierQuotes } from "@/api/bid/material";
export default {
name: "SupplierQuoteTab",
props: { materialId: [Number, String] },
data() { return { loading: false, list: [] }; },
created() { this.loadData(); },
methods: {
loadData() {
if (!this.materialId) return;
this.loading = true;
getSupplierQuotes(this.materialId).then(res => {
this.list = res.data || [];
this.loading = false;
}).catch(() => { this.loading = false; });
},
formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
},
formatPrice(price) {
if (!price && price !== 0) return '-';
return Number(price).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
getStatusType(status) {
const map = { 'awarded': 'success', 'submitted': 'primary', 'rejected': 'danger', 'draft': 'info' };
return map[status] || 'info';
},
getStatusText(status) {
const map = { 'awarded': '已中标', 'submitted': '已报价', 'rejected': '已拒绝', 'draft': '草稿' };
return map[status] || status;
},
headerStyle() {
return {
background: '#f5f7fa',
color: '#606266',
fontWeight: 600,
fontSize: '13px'
};
},
exportExcel() {
this.$message.info('导出功能开发中');
}
}
};
</script>
<style scoped>
.supplier-quote-tab {
padding: 16px;
}
/* 工具栏 */
.tab-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.tab-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* 表格样式 */
.quote-table {
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
>>> .el-table__header-wrapper {
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
/* 日期单元格 */
.date-cell {
font-size: 13px;
color: #606266;
font-weight: 500;
}
/* 供应商信息 */
.supplier-info {
padding: 4px 0;
}
.supplier-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
line-height: 1.4;
}
.supplier-contact {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.supplier-contact .phone {
margin-left: 8px;
color: #409eff;
}
/* 价格单元格 */
.price-cell {
font-size: 14px;
font-weight: 600;
color: #f56c6c;
}
/* 交期单元格 */
.days-cell {
font-size: 13px;
color: #e6a23c;
font-weight: 500;
}
/* 报价单号 */
.quote-no {
font-size: 12px;
color: #909399;
font-family: 'Courier New', monospace;
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
}
/* 状态标签 */
.status-tag {
font-weight: 500;
}
/* 空状态 */
.empty-text {
color: #909399;
font-size: 14px;
}
/* 行悬停效果 */
>>> .el-table__row:hover {
background-color: #f5f7fa !important;
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<div class="app-container">
<!-- 顶部标题栏 -->
<div class="detail-header">
<div class="header-title">
<span class="material-name">{{ material.materialName }}</span>
<el-button icon="el-icon-back" size="small" @click="goBack">返回列表</el-button>
</div>
</div>
<!-- 基础信息卡片 -->
<div class="basic-info-card">
<div class="card-header">
<span class="card-title">基础信息</span>
<div class="card-actions">
<el-button v-if="!isEditing" size="small" icon="el-icon-edit" @click="handleEdit">编辑</el-button>
<template v-else>
<el-button size="small" type="success" icon="el-icon-check" @click="handleSave">保存</el-button>
<el-button size="small" icon="el-icon-close" @click="handleCancel">取消</el-button>
</template>
</div>
</div>
<!-- 描述网格3 x 2行基础字段 -->
<div class="desc-grid">
<div class="grid-item">
<span class="grid-label">物料编码</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.materialCode" size="small" placeholder="请输入物料编码" />
<span v-else class="grid-value" :class="{ empty: !material.materialCode }">{{ material.materialCode || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">物料名称</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.materialName" size="small" placeholder="请输入物料名称" />
<span v-else class="grid-value" :class="{ empty: !material.materialName }">{{ material.materialName || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">厂家/品牌</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.brand" size="small" placeholder="如:汇川" />
<span v-else class="grid-value" :class="{ empty: !material.brand }">{{ material.brand || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">规格型号</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.spec" size="small" placeholder="如MD500T37G" />
<span v-else class="grid-value" :class="{ empty: !material.spec }">{{ material.spec || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">材质</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.material" size="small" placeholder="如:铝合金" />
<span v-else class="grid-value" :class="{ empty: !material.material }">{{ material.material || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">用途</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.purpose" size="small" placeholder="如:通用负载" />
<span v-else class="grid-value" :class="{ empty: !material.purpose }">{{ material.purpose || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">单位</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.unit" size="small" placeholder="如:台" />
<span v-else class="grid-value" :class="{ empty: !material.unit }">{{ material.unit || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">所属分类</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.categoryName" size="small" disabled />
<span v-else class="grid-value" :class="{ empty: !material.categoryName }">{{ material.categoryName || '' }}</span>
</div>
</div>
<div class="grid-item">
<span class="grid-label">备注</span>
<div class="grid-value-wrap">
<el-input v-if="isEditing" v-model="form.remark" size="small" placeholder="备注信息" />
<span v-else class="grid-value" :class="{ empty: !material.remark }">{{ material.remark || '' }}</span>
</div>
</div>
</div>
<!-- 性能参数区域 -->
<div class="sub-section">
<div class="sub-header">
<span>性能参数</span>
<el-button v-if="isEditing" size="mini" icon="el-icon-plus" @click="addParam">添加</el-button>
</div>
<el-table v-if="perfParams.length" :data="perfParams" border size="small" style="width:100%">
<el-table-column label="参数名">
<template slot-scope="scope">
<el-input v-if="isEditing" v-model="scope.row.name" size="small" placeholder="如:输出电压" />
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="参数值">
<template slot-scope="scope">
<el-input v-if="isEditing" v-model="scope.row.value" size="small" placeholder="如24VDC" />
<span v-else>{{ scope.row.value }}</span>
</template>
</el-table-column>
<el-table-column label="单位" width="120">
<template slot-scope="scope">
<el-input v-if="isEditing" v-model="scope.row.unit" size="small" placeholder="如V" />
<span v-else>{{ scope.row.unit }}</span>
</template>
</el-table-column>
<el-table-column v-if="isEditing" label="操作" width="70" align="center">
<template slot-scope="scope">
<el-button type="text" style="color:#f56c6c" @click="deleteParam(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!perfParams.length" class="sub-empty">暂无性能参数</div>
</div>
<!-- 描述区域已移除 -->
</div>
<!-- 下方Tab区域 - 仅报价历史 -->
<el-tabs v-model="activeTab" type="border-card" style="margin-top:16px">
<el-tab-pane label="供应商报价历史" name="supplier">
<SupplierQuoteTab :material-id="materialId" />
</el-tab-pane>
<el-tab-pane label="甲方报价历史" name="client">
<ClientQuoteTab :material-id="materialId" />
</el-tab-pane>
</el-tabs>
<!-- 同名称不同规格/品牌物料横向对比 -->
<CompareSection :material-id="materialId" :material="material" />
</div>
</template>
<script>
import { getMaterial, updateMaterial } from "@/api/bid/material";
import SupplierQuoteTab from "./components/SupplierQuoteTab";
import ClientQuoteTab from "./components/ClientQuoteTab";
import CompareSection from "./components/CompareSection";
export default {
name: "MaterialDetail",
components: { SupplierQuoteTab, ClientQuoteTab, CompareSection },
data() {
return {
materialId: null,
material: {},
form: {},
perfParams: [],
activeTab: "supplier",
isEditing: false
};
},
created() {
this.materialId = this.$route.query && this.$route.query.id;
this.loadMaterial();
},
methods: {
loadMaterial() {
if (!this.materialId) return;
getMaterial(this.materialId).then(res => {
this.material = res.data || {};
// 解析性能参数JSON
if (this.material.performanceParams) {
try {
const parsed = JSON.parse(this.material.performanceParams);
this.perfParams = Array.isArray(parsed) ? parsed : [];
} catch { this.perfParams = []; }
} else {
this.perfParams = [];
}
// 初始化表单数据
this.form = { ...this.material };
});
},
// 进入编辑模式
handleEdit() {
this.isEditing = true;
this.form = { ...this.material };
},
// 保存(实时保存,无需刷新)
async handleSave() {
try {
const saveData = {
materialId: this.materialId,
...this.form,
performanceParams: JSON.stringify(this.perfParams)
};
await updateMaterial(saveData);
this.$message.success("保存成功");
this.isEditing = false;
// 更新本地数据
this.material = { ...this.material, ...saveData };
this.material.perfArray = this.perfParams;
} catch (error) {
this.$message.error("保存失败:" + error.message);
}
},
// 取消编辑
handleCancel() {
this.isEditing = false;
this.form = { ...this.material };
// 恢复性能参数
if (this.material.performanceParams) {
try {
const parsed = JSON.parse(this.material.performanceParams);
this.perfParams = Array.isArray(parsed) ? parsed : [];
} catch { this.perfParams = []; }
} else {
this.perfParams = [];
}
},
// 添加性能参数
addParam() {
this.perfParams.push({ name: '', value: '', unit: '' });
},
// 删除性能参数
deleteParam(index) {
this.perfParams.splice(index, 1);
},
goBack() {
this.$router.push('/bid/material');
}
}
};
</script>
<style scoped>
/* ========= 顶部标题 ========= */
.detail-header {
background: linear-gradient(135deg, #1a2c4e 0%, #2c3e50 100%);
padding: 18px 24px;
border-radius: 6px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.header-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.material-name {
font-size: 22px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.header-title .el-button {
background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
}
.header-title .el-button:hover {
background: rgba(255,255,255,0.25);
border-color: rgba(255,255,255,0.5);
color: #fff;
}
/* ========= 基础信息卡片 ========= */
.basic-info-card {
background: #fff;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.basic-info-card:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
border-bottom: 1px solid #ebeef5;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
position: relative;
padding-left: 12px;
}
.card-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background: linear-gradient(180deg, #409eff 0%, #2c3e50 100%);
border-radius: 2px;
}
.card-actions .el-button {
padding: 6px 14px;
border-radius: 4px;
transition: all 0.3s ease;
}
.card-actions .el-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
/* ========= 描述网格3列 ========= */
.desc-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1px;
background: #ebeef5;
padding: 1px;
margin: 16px 24px;
border-radius: 6px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.05);
}
.grid-item {
display: flex;
align-items: stretch;
background: #fff;
min-height: 44px;
transition: background-color 0.2s ease;
}
.grid-item:hover {
background: #f8f9fb;
}
.grid-label {
width: 90px;
flex-shrink: 0;
background: linear-gradient(135deg, #f8f9fb 0%, #f0f2f5 100%);
color: #606266;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
border-right: 1px solid #ebeef5;
user-select: none;
}
.grid-value-wrap {
flex: 1;
display: flex;
align-items: center;
padding: 6px 14px;
min-width: 0;
}
.grid-value {
font-size: 13px;
color: #303133;
word-break: break-word;
line-height: 1.5;
font-weight: 500;
}
.grid-value.empty {
color: #c0c4cc;
font-style: normal;
}
.grid-value.empty::before {
content: '待填写';
font-size: 12px;
color: #c0c4cc;
}
.grid-value-wrap >>> .el-input__inner {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 10px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.grid-value-wrap >>> .el-input__inner:focus {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
}
/* ========= 子区域(性能参数) ========= */
.sub-section {
padding: 16px 24px;
margin: 0 24px 16px;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border: 1px solid #ebeef5;
}
.sub-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: #303133;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.sub-empty {
text-align: center;
color: #c0c4cc;
padding: 24px;
font-size: 13px;
background: #f8f9fb;
border-radius: 6px;
border: 1px dashed #dcdfe6;
}
/* Tab 区域美化 */
>>> .el-tabs--border-card {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
}
>>> .el-tabs--border-card > .el-tabs__header {
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
}
>>> .el-tabs--border-card > .el-tabs__header .el-tabs__item {
font-weight: 500;
transition: all 0.3s ease;
}
>>> .el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
background: #fff;
color: #409eff;
font-weight: 600;
}
>>> .el-tabs--border-card > .el-tabs__content {
padding: 0;
}
</style>

View File

@@ -7,6 +7,11 @@
<el-form-item label="物料编码" prop="materialCode">
<el-input v-model="queryParams.materialCode" placeholder="请输入物料编码" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="厂家/品牌" prop="brand">
<el-select v-model="queryParams.brand" placeholder="全部品牌" clearable style="width:160px" filterable>
<el-option v-for="b in brandList" :key="b" :label="b" :value="b" />
</el-select>
</el-form-item>
<el-form-item label="所属分类">
<el-select v-model="queryParams.categoryId" placeholder="全部分类" clearable style="width:160px">
<el-option v-for="c in flatCategories" :key="c.categoryId"
@@ -28,22 +33,37 @@
</el-col>
</el-row>
<el-table v-loading="loading" :data="materialList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="物料编码" prop="materialCode" width="130" />
<el-table-column label="物料名称" prop="materialName" :show-overflow-tooltip="true" />
<el-table-column label="所属分类" prop="categoryName" width="130" />
<el-table-column label="规格" prop="spec" :show-overflow-tooltip="true" width="130" />
<el-table-column label="型号" prop="modelNo" :show-overflow-tooltip="true" width="130" />
<el-table-column label="单位" prop="unit" width="70" />
<el-table-column label="品牌" prop="brand" width="100" />
<el-table
v-loading="loading"
:data="materialList"
@selection-change="handleSelectionChange"
border
stripe
style="width:100%"
:header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 700, fontSize: '13px' }"
:cell-style="{ fontSize: '13px', color: '#606266' }">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="物料编码" prop="materialCode" width="140" header-align="center" align="center" />
<el-table-column label="物料名称" prop="materialName" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="所属分类" prop="categoryName" width="130" :show-overflow-tooltip="true" />
<el-table-column label="厂家/品牌" prop="brand" width="130" :show-overflow-tooltip="true" />
<el-table-column label="规格型号" prop="spec" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="材质" prop="material" width="90" header-align="center" align="center" />
<el-table-column label="用途" prop="purpose" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="性能参数" width="200" :show-overflow-tooltip="true">
<template slot-scope="scope">
<span v-if="scope.row.performanceParams">{{ parsePerfParams(scope.row.performanceParams) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="80">
<template slot-scope="scope">
<el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="140">
<el-table-column label="操作" align="center" width="210" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-document" @click="handleDetail(scope.row)">详情</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" style="color:#f56c6c" @click="handleDelete(scope.row)">删除</el-button>
</template>
@@ -51,7 +71,8 @@
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog :title="title" :visible.sync="open" width="640px" append-to-body>
<!-- 新增/修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="760px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<el-row>
<el-col :span="12">
@@ -72,29 +93,64 @@
<el-input v-model="form.materialName" placeholder="请输入物料名称" />
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="规格" prop="spec">
<el-input v-model="form.spec" placeholder="如:500W/220V" />
<el-col :span="8">
<el-form-item label="厂家/品牌" prop="brand">
<el-input v-model="form.brand" placeholder="如:汇川" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="型号" prop="modelNo">
<el-input v-model="form.modelNo" placeholder="如:XD-2023A" />
<el-col :span="8">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="如:MD500T37G" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="台/件" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="如:台/件/米" />
<el-form-item label="材质" prop="material">
<el-input v-model="form.material" placeholder="如:铜/铝合金/PVC" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" />
<el-form-item label="用途" prop="purpose">
<el-input v-model="form.purpose" placeholder="如:通用负载" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="备注信息" />
</el-form-item>
<el-form-item label="性能参数">
<div class="perf-params-form">
<el-button type="success" size="mini" icon="el-icon-plus" @click="addPerfRow">添加参数</el-button>
<el-table :data="perfParams" border size="small" style="margin-top:8px" max-height="240">
<el-table-column label="参数名" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.name" placeholder="如: 功率" size="small" />
</template>
</el-table-column>
<el-table-column label="参数值" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.value" placeholder="如: 37" size="small" />
</template>
</el-table-column>
<el-table-column label="单位" width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.unit" placeholder="如: kW" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" style="color:#f56c6c" icon="el-icon-delete" @click="removePerfRow(scope.$index)" />
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="2" />
</el-form-item>
@@ -108,7 +164,7 @@
</template>
<script>
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial } from "@/api/bid/material";
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial, listManufacturer } from "@/api/bid/material";
import { getCategoryList } from "@/api/bid/category";
export default {
@@ -117,9 +173,11 @@ export default {
return {
loading: false, multiple: true, total: 0, materialList: [],
open: false, title: "",
queryParams: { pageNum: 1, pageSize: 10, materialName: null, materialCode: null, categoryId: null },
queryParams: { pageNum: 1, pageSize: 10, materialName: null, materialCode: null, categoryId: null, brand: null, spec: null },
form: {},
flatCategories: [],
brandList: [],
perfParams: [],
rules: {
materialCode: [{ required: true, message: "物料编码不能为空", trigger: "blur" }],
materialName: [{ required: true, message: "物料名称不能为空", trigger: "blur" }],
@@ -129,6 +187,7 @@ export default {
created() {
this.getList();
this.loadCategories();
this.loadBrands();
},
methods: {
loadCategories() {
@@ -136,6 +195,9 @@ export default {
this.flatCategories = this.flattenTree(res.data || [], 0, "");
});
},
loadBrands() {
listManufacturer().then(res => { this.brandList = res.data || []; });
},
flattenTree(nodes, depth, prefix) {
let result = [];
for (const n of nodes) {
@@ -157,21 +219,70 @@ export default {
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
resetQuery() { this.resetForm("queryForm"); this.handleQuery(); },
handleSelectionChange(sel) { this.multiple = !sel.length; this.ids = sel.map(s => s.materialId); },
handleAdd() { this.reset(); this.open = true; this.title = "新增物料"; },
handleAdd() {
this.reset();
this.perfParams = [];
this.open = true;
this.title = "新增物料";
},
handleUpdate(row) {
this.reset();
getMaterial(row.materialId).then(res => { this.form = res.data; this.open = true; this.title = "修改物料"; });
getMaterial(row.materialId).then(res => {
this.form = res.data;
// 解析性能参数JSON → table
this.perfParams = this.parsePerfParamsToArray(this.form.performanceParams);
this.open = true;
this.title = "修改物料";
});
},
handleDetail(row) {
this.$router.push({ path: '/bid/material/detail', query: { id: row.materialId } });
},
handleDelete(row) {
const ids = row.materialId || (this.ids || []).join(",");
this.$modal.confirm("确认删除?").then(() => delMaterial(ids)).then(() => { this.getList(); this.$modal.msgSuccess("删除成功"); });
},
handleStatusChange(row) { updateMaterial(row); },
reset() { this.form = { status: "0" }; this.resetForm && this.resetForm("form"); },
// 性能参数
addPerfRow() {
this.perfParams.push({ name: '', value: '', unit: '' });
},
removePerfRow(index) {
this.perfParams.splice(index, 1);
},
parsePerfParams(jsonStr) {
if (!jsonStr) return '-';
try {
const obj = JSON.parse(jsonStr);
const arr = Object.keys(obj).map(k => ({ name: k, value: obj[k] }));
return arr.map(p => p.name + ': ' + p.value).join('; ');
} catch { return jsonStr; }
},
parsePerfParamsToArray(jsonStr) {
if (!jsonStr) return [];
try {
// Try [{name,value,unit}] format first
const arr = JSON.parse(jsonStr);
if (Array.isArray(arr)) return arr;
// Try {key: value} format
return Object.keys(arr).map(k => ({ name: k, value: arr[k], unit: '' }));
} catch { return []; }
},
reset() {
this.form = { status: "0" };
this.perfParams = [];
this.resetForm && this.resetForm("form");
},
cancel() { this.open = false; this.reset(); },
submitForm() {
this.$refs["form"].validate(valid => {
if (!valid) return;
// 性能参数数组 → JSON string
if (this.perfParams.length) {
this.form.performanceParams = JSON.stringify(this.perfParams);
} else {
this.form.performanceParams = null;
}
const action = this.form.materialId ? updateMaterial : addMaterial;
action(this.form).then(() => { this.$modal.msgSuccess("操作成功"); this.open = false; this.getList(); });
});
@@ -179,3 +290,59 @@ export default {
}
};
</script>
<style scoped>
.app-container {
background: #fff;
padding: 16px 20px;
border-radius: 4px;
}
/* 搜索表单样式 */
.el-form--inline .el-form-item {
margin-bottom: 16px;
}
.el-form--inline .el-form-item:last-child {
margin-bottom: 0;
}
/* 操作按钮区域间距 */
.mb8 {
margin-bottom: 12px;
}
/* 表格行高 */
>>> .el-table .el-table__row {
height: 42px;
}
/* 表头字体加粗 */
>>> .el-table thead th {
font-weight: 700;
}
/* 开关居中 */
>>> .el-table .el-switch {
margin: 0 auto;
}
/* 对话框内表格边距 */
.perf-params-form {
margin-top: 4px;
}
/* 搜索框宽度统 */
.el-input--small {
width: 160px;
}
.el-form-item:last-child .el-input--small {
width: auto;
}
/* 按钮组间距 */
.el-button + .el-button {
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,53 @@
-- ========================================================
-- 物料信息模块数据库扩展脚本
-- 日期: 2025-05-27
-- 说明: 根据智采系统-物料信息需求扩展biz_material表
-- ========================================================
-- 1. 扩展物料表字段
-- 注意: spec字段保持为"规格型号"不变
-- 注意: remark字段保持不变
-- 注意: brand字段改为"厂家/品牌"
-- 使用存储过程判断字段是否存在避免IF NOT EXISTS语法错误
DELIMITER //
CREATE PROCEDURE AddColumnIfNotExists(IN tableName VARCHAR(64), IN colName VARCHAR(64), IN colDef VARCHAR(255))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = tableName
AND column_name = colName
) THEN
SET @sql = CONCAT('ALTER TABLE ', tableName, ' ADD COLUMN ', colName, ' ', colDef);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END //
DELIMITER ;
-- 添加性能参数字段
CALL AddColumnIfNotExists('biz_material', 'performance_params', "TEXT COMMENT '性能参数(JSON格式)'");
-- 添加材质字段
CALL AddColumnIfNotExists('biz_material', 'material', "VARCHAR(100) DEFAULT '' COMMENT '材质(铜/铝合金/PVC等)'");
-- 添加用途字段
CALL AddColumnIfNotExists('biz_material', 'purpose', "VARCHAR(500) DEFAULT '' COMMENT '用途'");
-- 添加物料图片URL字段
CALL AddColumnIfNotExists('biz_material', 'image_url', "VARCHAR(500) DEFAULT '' COMMENT '物料图片URL'");
-- 删除存储过程
DROP PROCEDURE IF EXISTS AddColumnIfNotExists;
-- 2. 修改brand字段注释为"厂家/品牌"
ALTER TABLE biz_material MODIFY COLUMN brand VARCHAR(100) COMMENT '厂家/品牌';
-- 3. 添加物料详情页菜单配置(使用INSERT IGNORE避免重复)
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES
(2011, '物料详情', 2001, 1, 'detail', 'bid/material/detail', NULL, 1, 0, 'C', '1', '0', 'bid:material:detail', '#', 'admin', NOW(), '', '', '');

View File

@@ -0,0 +1,52 @@
-- ========================================================
-- 物料信息模块数据库扩展脚本(二期)
-- 日期: 2026-05-27
-- 说明:
-- 1. 新增字段: performance_params, material, purpose, image_url
-- 2. 修改 brand 字段注释为"厂家/品牌"
-- 3. 添加物料详情页菜单配置
-- ========================================================
DELIMITER //
CREATE PROCEDURE AddColumnIfNotExists(IN tableName VARCHAR(64), IN colName VARCHAR(64), IN colDef VARCHAR(255))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = tableName
AND column_name = colName
) THEN
SET @sql = CONCAT('ALTER TABLE ', tableName, ' ADD COLUMN ', colName, ' ', colDef);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END //
DELIMITER ;
-- 添加性能参数字段JSON格式存储
CALL AddColumnIfNotExists('biz_material', 'performance_params', "TEXT COMMENT '性能参数(JSON格式)'");
-- 添加材质字段
CALL AddColumnIfNotExists('biz_material', 'material', "VARCHAR(100) DEFAULT '' COMMENT '材质(铜/铝合金/PVC等)'");
-- 添加用途字段
CALL AddColumnIfNotExists('biz_material', 'purpose', "VARCHAR(500) DEFAULT '' COMMENT '用途'");
-- 添加物料图片URL字段
CALL AddColumnIfNotExists('biz_material', 'image_url', "VARCHAR(500) DEFAULT '' COMMENT '物料图片URL'");
-- 删除存储过程
DROP PROCEDURE IF EXISTS AddColumnIfNotExists;
-- 修改brand字段注释为"厂家/品牌"
ALTER TABLE biz_material MODIFY COLUMN brand VARCHAR(100) COMMENT '厂家/品牌';
-- 添加物料详情页菜单配置
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES
(2011, '物料详情', 2001, 1, 'detail', 'bid/material/detail', NULL, 1, 0, 'C', '1', '0', 'bid:material:detail', '#', 'admin', NOW(), '', '', ''),
(2012, '物料新增', 2001, 2, 'add', 'bid/material/add', NULL, 1, 0, 'C', '1', '0', 'bid:material:add', '#', 'admin', NOW(), '', '', ''),
(2013, '物料修改', 2001, 3, 'edit', 'bid/material/edit', NULL, 1, 0, 'C', '1', '0', 'bid:material:edit', '#', 'admin', NOW(), '', '', '');

View File

@@ -230,6 +230,7 @@ VALUES
(2003,'报价请求',2000,3,'rfq','bid/rfq/index',NULL,1,0,'C','0','0','bid:rfq:list','form','admin',NOW(),'','',''),
(2004,'供应商报价',2000,4,'quotation','bid/quotation/index',NULL,1,0,'C','0','0','bid:quotation:list','money','admin',NOW(),'','',''),
(2005,'智慧比价',2000,5,'comparison','bid/comparison/index',NULL,1,0,'C','0','0','bid:comparison:list','chart','admin',NOW(),'','',''),
(2051,'比价详情',2005,1,'detail','bid/comparison/detail',NULL,1,0,'C','1','0','bid:comparison:detail','#','admin',NOW(),'','',''),
(2006,'采购单',2000,6,'purchaseorder','bid/purchaseorder/index',NULL,1,0,'C','0','0','bid:purchaseorder:list','shopping','admin',NOW(),'','',''),
(2007,'供应商评价',2000,7,'evaluation','bid/evaluation/index',NULL,1,0,'C','0','0','bid:evaluation:list','star','admin',NOW(),'','',''),
(2008,'订单异议',2000,8,'objection','bid/objection/index',NULL,1,0,'C','0','0','bid:objection:list','warning','admin',NOW(),'','',''),

34
sql/client_quote_fix.sql Normal file
View File

@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS biz_client_quote (
quote_id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL DEFAULT 1,
quote_no VARCHAR(50) DEFAULT '',
client_name VARCHAR(200) DEFAULT '',
rfq_id BIGINT DEFAULT NULL,
rfq_no VARCHAR(50) DEFAULT '',
rfq_title VARCHAR(200) DEFAULT '',
status VARCHAR(20) DEFAULT 'draft',
validity_date DATETIME DEFAULT NULL,
total_amount DECIMAL(15,4) DEFAULT 0,
currency VARCHAR(10) DEFAULT 'CNY',
remark TEXT,
create_by VARCHAR(64) DEFAULT '',
create_time DATETIME,
update_time DATETIME,
PRIMARY KEY (quote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='甲方报价单';
CREATE TABLE IF NOT EXISTS biz_client_quote_item (
item_id BIGINT NOT NULL AUTO_INCREMENT,
quote_id BIGINT NOT NULL,
material_name VARCHAR(200) DEFAULT '',
spec VARCHAR(500) DEFAULT '',
model_no VARCHAR(200) DEFAULT '',
unit VARCHAR(50) DEFAULT '',
quantity DECIMAL(15,4) DEFAULT 0,
cost_price DECIMAL(15,4) DEFAULT 0,
unit_price DECIMAL(15,4) DEFAULT 0,
total_price DECIMAL(15,4) DEFAULT 0,
delivery_days INT DEFAULT 0,
remark VARCHAR(500) DEFAULT '',
PRIMARY KEY (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='甲方报价明细';