共计 10497 个字符,预计需要花费 27 分钟才能阅读完成。
跟单生产单与采购合同模块设计文档
日期:2026-06-02
版本:V1.2
范围:新增独立模块,供
merchandiser(跟单)与admin(管理员)角色账号使用。
原则:不改动现有备货、积加同步、销量核对、Dashboard、大屏等业务链路;复用登录鉴权、用户角色和前端基础布局。
1. 目标
新增一套“生产单解析 + 采购合同制作”功能:
- 跟单和 Admin 账号可以进入合同页面。
- 系统可导入同类生产单 Excel,解析并落库。
- 页面可按生产单号或 SKU/ 货号查询并导入产品明细。
- 合同抬头信息可通过后台预设模板自动填充;合同号由系统自动按规则生成。
- 采购单价支持人工手填,同时提供人工维护报价作为下拉参考。
- 合同页面外观和导出 Excel 格式参考
docs/ 采购单.xls,导出统一为.xlsx。 - 页面支持编辑单元格、合并单元格、保存合同。
- 合同保存后,所有明细、图片 URL、单元格布局信息均存入 PostgreSQL,后续可再次打开、复制、导出。
- 权限隔离:跟单互看可见,但不能互相限制查看;Admin 拥有全部权限。
- 重复导入确认流程必须避免前后端重复传输大体积解析结果。
2. 样本结构调研
2.1 生产单样本
观察到的共同结构:
- 主数据集中在
Sheet1。 - 标题在
A1,如26-809YS-031 单生产任务单。 - 基础信息含有固定段落前缀,如
一、品名、七:交货期等。 - 明细表头通常位于第 6 行,包含货号、客号、货物规格描述、数量等。
- 底部包含包装说明、PO 号、制单人及日期。
需要兼容的差异与解析策略:
- 文件名和标题可能有细微差异和冗余后缀。
- 由于表头可能不在固定行,后端解析采用“关键字游标寻址法”,逐行扫描,匹配到“货号”“数量”等关键字所在行后定位表头,下一行作为明细起始行。
- 首期不解析 Excel 内嵌图片,仅解析文本数据。
生产单唯一识别规则:
- 生产单号格式示例:
26-809YS-031 - 以
production_order_no作为唯一识别码。 - 若再次导入同一生产单号,视为重复导入。
- 系统在覆盖旧记录前必须弹窗提醒。
- 重复导入时只覆盖生产单主数据和明细数据,不回写已生成的历史合同。
2.2 采购单模板
- 顶部结构:公司名、合同号码、日期、甲方 / 乙方、联系人、地址、电话、传真、包装说明。
- 明细表头:工厂货号、客户号、图片、货物规格描述、数量(套)、单价、总价、备注。
- 底部包含合同条款、备注、双方代表签章。
- 包含较多合并单元格,如标题行、包装说明行、图片区域、签章区域等。
3. 模块边界
3.1 新增前端入口
- 路由:
/contracts - 页面文件建议:
frontend/src/views/MerchandiserContractsView.vue - 前端路由 meta:
meta: {requiresAnyRole: ['admin', 'merchandiser'] } - 导航入口:当用户角色为
admin或merchandiser时显示“采购合同”。
3.2 新增后端入口
新增独立控制器和相关服务:
控制器:
ProductionOrderController.php:生产单导入、查询、覆盖提醒PurchaseContractController.php:合同 CRUD、复制、作废、导出ContractTemplateController.php:合同模板配置PriceQuoteController.php:人工报价批量上传、查询CommonUploadController.php:图片上传,若无通用接口则新增
服务:
ProductionOrderImportService.php:Excel 解析PurchaseContractService.php:合同流转、明细处理ContractSequenceService.php:合同号自动生成服务ManualQuoteService.php:人工报价导入与查询服务
4. 数据库设计
4.1 scm_contract_templates
用于维护合同的主体模板、默认文案和签章区固定文字。
idtemplate_nameparty_a_nameparty_a_contactparty_a_phoneparty_a_addressparty_a_faxparty_b_nameparty_b_contactparty_b_phoneparty_b_addressparty_b_faxdefault_packing_textdefault_terms_textdefault_remark_textdefault_sign_text_jsonis_defaultcreated_atupdated_at
4.2 scm_production_orders
idproduction_order_nopo_noproduct_namestyle_texttotal_qtydelivery_datemaker_namemade_datesource_file_namesource_file_hashraw_header_jsoncreated_byupdated_bycreated_atupdated_at
约束建议:
unique(production_order_no)index(po_no)
4.3 scm_production_order_items
idproduction_order_idline_nofactory_skucustomer_skuspec_descriptionmaterial_textqtyerp_namesticker_codepacking_textraw_row_jsoncreated_atupdated_at
4.4 scm_purchase_contracts
idcontract_nocontract_datetemplate_idstatus:draft、saved、voidparty_a_nameparty_a_contactparty_a_phoneparty_a_addressparty_a_faxparty_b_nameparty_b_contactparty_b_phoneparty_b_addressparty_b_faxpacking_textgoods_summaryremark_textterms_textsign_text_jsonamount_totalui_versionsheet_state_jsonmerged_cells_jsonsource_production_order_idscreated_byupdated_byvoided_byvoided_atcreated_atupdated_at
合同编号规则:
- 合同号格式:
ADL26060201 - 组成规则:固定前缀
ADL+ 当天日期YYMMDD+ 两位流水号 - 示例:
ADL26060201 - 同一天合同量不会超过 100,因此两位流水足够
- 合同号允许 Admin 和跟单手工改,但保存时必须做唯一性重复校验
补充约束:
ui_version默认写入1.0sheet_state_json保存中立化后的表格结构,不直接保存表格引擎原始内部 JSON- 若未来更换前端表格方案,可根据
ui_version做兼容解析
4.5 scm_purchase_contract_items
idcontract_idproduction_order_idproduction_order_item_idline_nofactory_skucustomer_skucolor_textimage_urlspec_descriptionqtyunit_pricetotal_priceremarkraw_row_jsoncreated_atupdated_at
4.6 scm_manual_price_quotes
用于保存人工维护报价。
idsource_batch_nofactory_skutax_exclusive_unit_pricecreated_byupdated_bycreated_atupdated_at
报价 Excel 首期最少字段:
- SKU
- 不含税单价
4.7 scm_import_batches
用于保存“上传解析完成但尚未确认覆盖”的临时批次头信息。
idbatch_idmodule_type:production_order、manual_price_quotestatus:pending_confirm、confirmed、expired、appliedsummary_jsoncreated_byexpires_atcreated_atupdated_at
清理策略:
- 临时批次默认有效期建议为
24小时 - 超过
24小时且仍处于pending_confirm状态的数据,视为垃圾数据 - 后端需通过 Laravel 调度任务定时清理
- 结合当前仓库实际情况,调度建议注册在
backend/backend_laravel/routes/console.php - 当前实现命令为
imports:cleanup-batches,调度时间为每天02:10
4.8 scm_import_batch_rows
用于保存导入批次中的临时解析结果和差异对比。
idbatch_idrow_nobusiness_keyconflict_typeincoming_jsonexisting_jsoncreated_atupdated_at
5. API 设计
统一前缀:/api/merchandiser
说明:
- Admin 也通过该路由访问,由权限中间件放行。
5.1 生产单 API
POST /production-orders/import:上传 Excel 解析;若发现已有相同production_order_no,后端先将解析结果写入临时批次表,返回import_batch_id、冲突摘要和覆盖提示信息。POST /production-orders/import-confirm:前端只提交batch_id与确认动作,后端依据临时批次执行覆盖导入。GET /production-orders:分页查询。GET /production-orders/{id}:查看生产单头和明细。POST /production-orders/lookup-items:输入生产单号或 SKU 搜索明细。
5.2 合同 API
GET /contracts:合同列表。POST /contracts:新建合同草稿,并自动生成ADL格式合同号,拉取默认模板数据。GET /contracts/{id}:打开合同详情。PUT /contracts/{id}:保存合同,含表格状态、合并单元格、合同号重复校验。POST /contracts/{id}/import-items:将生产单明细导入已有合同。POST /contracts/{id}/copy:复制合同内容,但生成新合同号。POST /contracts/{id}/void:作废合同。GET /contracts/{id}/export:强制先保存后,再基于保存后的 JSON 结构导出.xlsx。
5.3 模板与报价 API
GET /contract-templates:获取主体模板列表。POST /contract-templates:新增模板。PUT /contract-templates/{id}:更新模板。GET /manual-price-quotes:查询人工报价。POST /manual-price-quotes/import:批量上传人工报价 Excel;若发现重复 SKU,后端写入临时批次表并返回import_batch_id、旧价 / 新价差异列表。POST /manual-price-quotes/import-confirm:前端提交batch_id和逐行勾选的覆盖 SKU 列表,后端据此执行覆盖。GET /sku-prices?sku={sku}:基于人工报价返回价格建议列表;合同录入阶段按客户 SKU 列发起查询,选中建议后将建议项 SKU 回填到合同客户 SKU 列。- 当客户 SKU 失焦后仍未匹配到任何报价时,前端提示“单价没匹配到,请手动输入”,并将单价置为
0.00,同时高亮价格单元格,直到用户手动录入价格。 POST /upload-image:单图上传,返回 URL,用于插入合同明细行。
6. 前端与 Excel 交互方案
6.1 后端解析
首期采用原生 ZipArchive + XML 解析样本 .xlsx:
- 不新增 Composer 依赖,优先适配当前服务器环境
.xls兼容保留到后续服务器 Composer 条件满足后再接入- 人工报价导入与生产单导入共用同一类轻量
.xlsx解析思路 - 使用关键字寻址法匹配表头,提高对易变模板的容错率
- 提取明细文本,舍弃首期复杂图片提取
- 完整保存原始 JSON,防止信息丢失
6.2 前端电子表格选型与交互
优先方向:
- 采用尽量轻量的 Web Spreadsheet 方案
- 先满足合并单元格、编辑、序列化保存、图片列上传、单价列联想
- 在正式选型前必须做一次小型技术验证(Spike)
当前推荐方向:
- 首选轻量型表格方案,优先评估
@wolf-table/table - 先手写一份包含合并单元格、图片、样式的最小状态样本,验证能否稳定翻译到
exceljs - 如翻译层复杂度过高或样式错位严重,优先回退到更易导出的轻量 DOM 表格方案
中立化 JSON 契约要求:
- 后端和前端在正式开工前需要先对
sheet_state_json的基础结构达成一致 - 中立结构中不得包含表格引擎专用类名、渲染函数名、运行时对象引用
- 只保存纯数据结构,例如:
{
"cells": [
{
"row": 1,
"col": 1,
"val": "合同号码",
"style": {
"bold": true,
"align": "center"
}
}
],
"rowHeights": {"1": 28},
"colWidths": {"1": 120}
}
- 该契约确定后,后续即使更换前端表格引擎,也能继续解析历史合同
交互要求:
- 图片列:点击单元格后上传图片,成功后渲染图片
- 单价列:编辑时显示下拉参考,同时允许手工输入
- 总价列:自动计算
数量 * 单价 - 同一个 SKU 但不同颜色时:
- 保留多行
- 只合并“货物描述”单元格
- 数量、价格、总价不合并
6.3 Excel 导出
- 前端使用
exceljs生成.xlsx - 导出前强制先保存合同
- 若当前页面存在未保存改动,先执行保存,保存成功后再导出
- 导出格式视觉上对齐
docs/ 采购单.xls - 图片上传时必须限制体积,首期建议单图不超过
2M - 图片访问地址必须允许当前站点域名访问,避免导出时跨域图片读取失败
- 前端插入图片时,必须逐张捕获下载失败异常,不能因为单张图片
404或跨域失败导致整个合同导出中断 - 若使用
fetch,应读取arrayBuffer();若使用axios,应显式设置responseType: 'arraybuffer' - 若前端导出在多图场景下频繁失败或浏览器内存占用过高,二期应切换为后端生成导出文件
7. 权限与隔离设计
7.1 前端层面
- 路由对
admin和merchandiser开放,其余角色隐藏并重定向 - 合同列表页中,Admin 和跟单都可看到所有合同
7.2 后端层面
- Admin 可查看、编辑、复制、作废、导出所有合同
- 跟单可查看、编辑、复制、作废、导出所有合同
- 作废后的合同仍可查看和导出,但不能再编辑
说明:
- 权限重点不再是“谁能改谁的合同”,而是“作废后的合同不可再编辑”
- 因此后端需要校验:
- 非法角色不可访问
status=void的合同不可再次保存或导入明细
8. 实施计划
阶段 1:基础骨架与模板设置
- 新增 8 张表 migration 及 Models
- 开发合同模板 CRUD 接口及管理员页面
- 确认 Excel 解析依赖并实现
ProductionOrderImportService - 编写单元测试,重点覆盖正常表头和含空行表头定位
阶段 2:生产单覆盖与人工报价
- 实现基于临时批次表的生产单重复检测与覆盖提醒机制
- 实现人工报价 Excel 批量导入
- 实现人工报价查询与
sku-prices接口 - 实现图片上传基础 API
阶段 3:合同核心逻辑
- 实现合同序列号生成策略
- 实现合同 CRUD、复制、作废
- 实现生产单明细导入合同
- 实现作废状态只读控制
阶段 4:前端合同编辑器
- 引入轻量表格组件
- 实现左侧查询与明细导入逻辑
- 实现图片单元格上传
- 实现单价单元格下拉推荐
- 实现表格 JSON 状态双向序列化与保存
阶段 5:导出与联调
- 实现基于
exceljs的.xlsx导出 - 比对原模板样式
- 增加合同号重复校验测试
- 增加作废后禁止编辑测试
- 增加生产单重复覆盖测试
- 增加单图失败时不影响整体导出的异常处理验证
阶段 6:上线准备
- 手工验证:权限、自动编号、手工改号、价格联想、合并单元格持久化、作废只读、导出前自动保存
- 执行
php artisan migrate - 发布前端构建产物
- 无需干预现有积加同步和备货任务
9. 已确认规则
- 生产单号格式:
26-809YS-031 - 合同号格式:
ADL26060201 - 生产单重复导入:以生产单号判重,覆盖旧生产单数据,覆盖前弹窗提醒,不回写历史合同
- 合同复制:复制内容,但生成新合同号
- 合同作废:仍可查看和导出,但不能再编辑
- 报价来源:采用单独的人工维护报价来源,支持 Excel 批量上传
- 报价最少列:
SKU、不含税单价 - 报价判重:SKU 本身已包含颜色信息,因此按
SKU判重 - 报价重复上传:罗列重复上传的 SKU,展示旧价格和新价格,并支持逐行勾选是否覆盖
- 导出方式:前端
exceljs导出,且先保存后导出 - 模板方式:支持多模板下拉选择
- 图片方式:报价表不带图片;合同编辑页由跟单手动上传图片
- 图片映射:每个 SKU 对应一张图片;若合同中有多个 SKU,则对应多张图片
- 合并规则:同一个 SKU 不同颜色时,仅合并货物描述单元格,其他列不合并
- 作废合同限制:禁止再次导入生产单明细
- 导入确认机制:重复导入时,后端先将解析结果写入临时批次表,前端只提交
batch_id和确认项 - 前端状态持久化:保存中立结构 JSON,并记录
ui_version - 临时批次清理:通过 Laravel Scheduler 每日清理超时未确认的导入批次
- 当前已落地
imports:cleanup-batches调度任务,负责清理过期的导入临时批次及其明细行
10. 实现建议补充
- 图片上传首期建议保留“单条手动上传”。
- 如后续跟单反馈上传成本高,可在第二期补一个“按 SKU 批量传图并自动匹配”的增强入口。
- 当前已补充“按 SKU 批量传图并自动匹配”入口,复用现有单图上传接口,按文件名去扩展名后匹配合同明细中的
SKU / 客户 SKU。
11. 导出模板补充
- 合同导出样式不再手工拼整张工作表,而是改为基于
docs/ 采购单.xls转换出的运行时模板导出。 - 运行时模板文件放在
frontend/public/purchase-contract-template.xlsx,其版式来源于docs/ 采购单.xls。 - 当前导出规则:
- 先加载模板工作簿。
- 再按合同明细数量动态插入或删除明细行。
- 明细区以下的合计、备注、签章区整体下移。
- 货物描述的合并状态继续读取
merged_cells_json。 - 当前模板细节约束:
- 顶部第 1-9 行除合同号码与日期外,其余模板内容保持原样。
- 明细区默认单行高度为
144pt,描述合并行按合并组重新分配高度。 - 明细区单元格统一补齐边框,图片在图片列内居中显示。
- 备注区固定保留:
1、本合同所列价格为不含 17% 增殖税发票的价格。 - 底部右侧乙方签章列统一左对齐。
- 从
六、备注:的上一行开始取消边框,且该行及其以下各行统一使用20.25pt行高。 - 底部签章区的
日期:保留在原标签单元格内,日期值写入其右侧单元格。 - 若后续业务继续调整
docs/ 采购单.xls版式,需要同步刷新frontend/public/purchase-contract-template.xlsx。
12. 2026-06-03 单价联想补充
- 当合同明细行的
SKU只命中 1 条人工报价时,前端自动回填unit_price,并同步重算total_price。 - 该逻辑只在当前行尚未填写单价和总价时生效,不覆盖已手工录入的价格。
- 命中多条报价时不自动覆盖,继续保留手动展开建议列表的交互方式。
- 生产单明细导入合同后,对新增且未填价的行同样执行“单条报价自动回填(single quote auto-fill)”。
2026-06-03 补充:生产单导入统一关键词搜索
- 合同编辑页中的“导入生产单明细”改为统一关键词搜索。
- 前端不再区分“按生产单号”或“按 SKU”下拉。
- 用户输入生产单号、工厂 SKU、客户 SKU,统一走同一个搜索接口。
- 后端搜索实现同时匹配:
production_order_nofactory_skucustomer_sku- 搜索规则统一为大小写不敏感。
2026-06-04 Full-page layering and overlay note
- The desktop full-page sheet now uses fixed layer roles: cell 1, active/editing 2, gutter 10, sticky header 11, sticky corner 12, toolbar 20.
- Unit-price suggestions no longer render inside the sheet cell. They render through Teleport to=”body” to avoid clipping by scroll containers, sticky headers, or frozen gutters.
- Overlay position is computed from the active unit-price cell getBoundingClientRect() and refreshed on scroll, resize, and edit-state changes.
- The hidden legacy editor remains in the page only for payload compatibility during save/export; desktop interaction stays in ContractSheetDesktop.
2026-06-04 Void contract delete rule
- Void contracts can now be deleted from the contract draft list and the full-page editor.
- Deletion is restricted to status=void only.
- Delete removes the contract header and all contract detail rows together.
2026-06-05 报价图片上传补充
scm_manual_price_quotes当前已包含image_url字段,用于持久化报价图片路径。- 报价管理页图片上传链路采用:
- 新图先保存
- 更新
image_url - 成功后对旧图做 best-effort 删除
- 删除动作只允许处理本系统受管目录:
uploads/manual-price-quotes/...- 删除失败不阻断本次上传成功;页面仍以数据库中最新
image_url为准。 - 长期治理仍保留后续预案:
- 通过定时巡检或对账脚本清理历史孤儿文件
正文完