Files
xin-procurement/开发计划.md
T
2026-08-10 10:09:51 +08:00

43 KiB
Raw Blame History

****# 订货采购系统 · 开发计划

版本:V2.02026-07-23 依据:《项目需求规划书.md》V1.0 技术栈:Laravel 13 + XinAdminAnnoRoute / XinTable / XinForm+ 微信小程序(前端形态,独立项目)

V2.0 变更要点(相对 V1.0

  1. PC 前端不做国际化 —— 业务页面文案全部硬编码中文,不建 web/locales/** 业务语言包,不使用 useTranslation();菜单 sys_rule.local 留空、name 直接写中文(layout 在 local 为空时自动回退显示 name
  2. 业务代码进 app/ 目录 —— 遵循 Laravel 标准目录规范(app/Modelsapp/Http/Controllersapp/Http/Requestsapp/Services),不再往 modules/ 写业务代码。AppServiceProvider::boot() 已注册 $annoRoute->register(app_path('Http/Controllers')) 递归扫描,新控制器零注册即生效,无需新建 ServiceProvider
  3. 小程序用户并入现有 user —— mini_user 表已删除(迁移已改、库已重建);认证复用现有 users guardprovider 已指向 App\Models\UserModel),config/auth.php 零改动
  4. 补齐后台 API 与前端 API 封装的逐接口明细
  5. 导出方案定案并已就绪 —— Excel 用 maatwebsite/excel ^3.1、PDF 用 barryvdh/laravel-dompdf ^3.1均已安装);中文字体 SimHei 已注册进 DomPDFresources/fonts/simhei.ttfAppServiceProvider 启动时幂等注册,已验证中文 PDF 生成);所有导出接口支持 ?format=xlsx|pdf,详见 2.5

一、进度总览

阶段 内容 状态
数据库迁移(user 表扩展 + 16 张业务表,共 17 张) 已完成(2026-07-23migrate:fresh 已执行)
模型层(UserModel 扩展 + 17 个新模型,含关系/常量/工厂) 已完成(2026-07-2318 模型 + 9 工厂 + 3 Service 骨架,104 项自检通过)
PC 后台 APIapp/Http/Controllers 下 5 个业务域 + FormRequest + Service 已完成(2026-07-2314 控制器 + 9 FormRequest + 6 Service/Export44 路由与权限点验证通过;对账明细操作拆为独立 ReconItemController 以匹配 recon.item.item.update 权限点)
小程序 APIapp/Http/Controllers/Mini,微信登录 + 门店端 + 供应商端) 已完成(2026-07-237 控制器 + WechatService 完整实现 + 20 路由验证通过;#7 已批准:purchase_order_item 补 supplier_confirmed_at
PC 前端页面 + 菜单权限 Seeder(硬编码中文,无 i18n 已完成(2026-07-2312 页面 + 11 API 封装 + 12 domain 类型 + ProcurementSeeder 62 节点已入库授权,tsc/vite build 通过)
PHPUnit 功能测试 已完成(2026-07-238 测试类 38 用例 225 断言全过;测试驱动修复:订单 summary DATE_FORMAT 方言适配、phpunit 切 SQLite :memory:、启用 pdo_sqlite 扩展)

二、架构设计

2.1 目录结构(app/Laravel 标准规范)

app/
├── Models/                          # 所有 Eloquent 模型(扁平目录,Laravel 惯例)
│   ├── UserModel.php                # 现有 → 扩展:小程序字段 + 关系 + 常量
│   ├── CustomerLevelModel.php
│   ├── StoreModel.php
│   ├── SupplierModel.php
│   ├── NoticeModel.php
│   ├── ProductCategoryModel.php
│   ├── ProductModel.php
│   ├── ProductPriceModel.php
│   ├── StoreOrderModel.php
│   ├── StoreOrderItemModel.php
│   ├── PurchaseOrderModel.php
│   ├── PurchaseOrderItemModel.php
│   ├── PurchaseAllocationModel.php
│   ├── ReconciliationModel.php
│   ├── ReconciliationItemModel.php
│   ├── StatementModel.php
│   ├── StatementItemModel.php
│   └── SettlementModel.php
├── Http/
│   ├── Controllers/                 # AnnoRoute 自动递归扫描 *Controller.php
│   │   ├── Customer/                # 客户等级 / 门店 / 供应商 / 小程序用户 / 通知
│   │   ├── Product/                 # 商品分类 / 商品档案(含价格体系)
│   │   ├── Order/                   # 门店订单
│   │   ├── Purchase/                # 采购单(生成 / 导出 / 发送 / 分摊)
│   │   ├── Recon/                   # 财务对账 / 门店对账单 / 结算表
│   │   └── Mini/                    # 小程序专用(authGuard: 'users'
│   └── Requests/                    # FormRequest,按业务域分子目录
│       ├── Customer/  Product/  Purchase/  Recon/  Mini/
├── Services/                        # 新目录:复杂业务逻辑(控制器只做参数校验与编排)
│   ├── BillNumberService.php        # 单号生成:PO/SO/RC/ST/JS + yyyyMMdd + 4位序列
│   ├── PurchaseGenerateService.php  # C1 订单汇总生成采购单
│   ├── PurchaseAllocateService.php  # D3 采购金额按订货比例分摊
│   ├── ReconciliationBuildService.php # 对账明细构建(品类/供应商筛选)
│   ├── StatementGenerateService.php # 门店对账单生成(回款周期快照)
│   ├── WechatService.php            # code2Session / 手机号解密(HTTP 调微信 API
│   └── ExportService.php            # 导出统一入口:按业务类型 + format 分发到 Exports 类 / PDF 模板
└── Exports/                         # Laravel Excel 导出类(FromQuery + WithHeadings + WithMapping + WithStyles
    ├── PurchaseOrderExport.php      # C2/C3 采购单导出(all 全品类 / category 蔬果分类)
    ├── StatementExport.php          # 门店对账单导出
    └── SettlementExport.php         # D10 结算表导出
resources/
├── fonts/simhei.ttf                 # 中文字体(DomPDF 用,已入库)
└── views/exports/                   # PDF 导出 Blade 模板(统一 font-family: SimHei
    ├── purchase.blade.php
    ├── statement.blade.php
    └── settlement.blade.php
database/factories/                  # 模型工厂(Laravel 默认位置)

2.2 认证体系(双端共用 Sanctum,零配置改动)

Guard 模型 说明
PC 后台 sys_users(现有默认) SysUserModel AnnoRoute 不传 authGuard 即走默认;abilities 权限点校验
小程序 users(现有) App\Models\UserModel Sanctum tokenabilities = ['mini']
  • config/auth.php 无需改动users guard / provider 已存在且指向 App\Models\UserModel
  • 小程序控制器类级声明:#[RequestAttribute('/mini', 'mini', authGuard: 'users')];登录等公开接口用 authorize: false
  • AuthGuardMiddlewaretokenable_type 比对 guard 的 provider model,天然隔离双端:后台 token 无法访问 /mini/*,反之亦然
  • Token 共用 sys_access_token 表(多态,SysAccessToken 已在 SystemUserServiceProvider 全局注册)

2.3 微信登录流程

小程序 wx.login() 拿 code
  → POST /mini/auth/login {code}authorize: false
  → WechatService::code2Sessionappid + secret 换 openid/session_key
  → UserModel::firstOrCreate(openid) → createToken('mini', ['mini']) → 返回 token + 用户信息
  → 更新 last_login_at
小程序 wx.getPhoneNumber 拿 phoneCode
  → POST /mini/auth/phone {phoneCode} → WechatService::getPhone 换手机号 → 绑定 user.phone
  → 按手机号匹配 store.phone / supplier.phone
      命中门店 → type=1 + store_id;命中供应商 → type=2 + supplier_id;都不命中 → type=0 待绑定(后台人工处理)

微信配置:config/services.php 增加 wechat.mini,读取 WECHAT_MINI_APPID / WECHAT_MINI_SECRET(需业务方提供)。

实现说明:WechatService 基于 EasyWeChat 6.xw7corp/easywechatMiniApp\Application 封装,code2Session / getPhoneNumber 走 SDK Utilsaccess_token 由 SDK 自动管理);测试通过 WechatService::setHttpClient() 注入 Symfony MockHttpClient 拦截微信调用,WechatServiceAppServiceProvider 注册为单例以保证注入生效。

2.4 通用约定

  • REST 命名与 XinTable 默认一致GET {api}=query、POST {api}=create、PUT {api}/{id}=update、DELETE {api}/{id}=delete
  • 单号生成BillNumberService::make('PO')PO202607230001(采购 PO / 订货 SO / 对账 RC / 对账单 ST / 结算 JS),按「前缀+当日」计数自增
  • 快照原则:下单/生成采购单/生成对账单时冗余品名、规格、单价;历史单据不受调价影响
  • 列表查询:控制器继承 Modules\Common\Http\Controllers\BaseController,声明 $searchField(支持 = like date betweenDate 等算子)/ $quickSearchField,用 buildSearch() 组装
  • 数据隔离:小程序端一切查询强制以当前用户 store_id / supplier_id 过滤,详情接口校验归属
  • 金额字段 casts decimal:2,重量 decimal:3;金额运算用 bcmathbcadd/bcmul),禁止浮点直算
  • 状态字段一律类常量(如 StoreOrderModel::STATUS_PENDING),控制器/前端 render 均引用常量映射,禁止魔术数字
  • 前端文案硬编码中文:页面 title、表格列名、按钮文字直接写字面量;错误提示走后端返回的 msg(后端校验消息也直接写中文,不用 __()

2.5 导出方案(Excel + PDF,已就绪)

依赖(已安装并验证)

用途 版本 状态
Excelxlsx/csv maatwebsite/excelPhpSpreadsheet ^3.1 已安装
PDF barryvdh/laravel-dompdf(纯 PHP,无外部二进制) ^3.1 已安装,config/dompdf.php 已发布
PDF 中文 SimHei 黑体 resources/fonts/simhei.ttf 已入库;AppServiceProvider::boot() 幂等注册到 DomPDF(缓存写入 storage/fonts/,已 gitignore);模板统一 font-family: SimHei

统一入口

// 控制器只调一行,format 校验在 ExportService 内完成(xlsx|pdf,默认 xlsx
return app(ExportService::class)->download('purchase', $purchase, $format, type: 'all');
return app(ExportService::class)->download('statement', $statement, $format);
return app(ExportService::class)->download('settlement', $settlement, $format);
  • Excel 分支Excel::download(new PurchaseOrderExport($purchase, $type), $filename),导出类放 app/Exports/,实现 FromCollection + WithHeadings + WithMapping + WithStyles(表头加粗冻结首行)
  • PDF 分支Pdf::loadView('exports.purchase', compact(...))->setPaper('a4')->download($filename);模板放 resources/views/exports/,顶部公共样式 body { font-family: SimHei },金额列右对齐、表格细边框
  • 文件名规范{单号}_{业务名}.{ext},如 PO202607230001_采购单.xlsx;中文文件名由 Laravel 下载响应自动做 RFC 5987 编码(Content-Disposition: attachment; filename*=UTF-8''...),前端从响应头取或按单号兜底拼接
  • 同步 vs 异步:当前数据量用同步流式下载(不落盘);后续量大再切队列导出 + storage/app/exports 暂存 + 通知下载,ExportService 签名保持不变
  • PDF 体积提示:DomPDF 全量嵌入字体,单文件约 10MB 量级,属正常现象;若业务方介意可后续评估换 Snappy(需 wkhtmltopdf 二进制)

三、阶段二:模型层(app/Models)

3.1 UserModel 扩展(改现有文件)

内容
fillable 增加 openid, unionid, phone, avatar, type, store_id, supplier_id, status, last_login_at移除不存在的 mobileuser 表无此列,系历史遗留)
casts last_login_at => datetime
常量 TYPE_PENDING=0, TYPE_STORE=1, TYPE_SUPPLIER=2STATUS_NORMAL=1, STATUS_DISABLED=0
关系 store() belongsTo StoreModelsupplier() belongsTo SupplierModelnotices() hasMany NoticeModel(外键 user_id

3.2 新模型清单

模型 要点
CustomerLevelModel customer_level hasMany stores
StoreModel store SoftDeletesbelongsTo levelhasMany orders / userspayment_cycle_days 影响对账单
SupplierModel supplier SoftDeleteshasMany products / purchaseItems / users
NoticeModel notice belongsTo usercasts data => array;常量 TYPE_ORDER/TYPE_PRICE/TYPE_SYSTEM
ProductCategoryModel product_category parent/children 自关联;提供静态 getTreeData()(分类树/级联选项复用)
ProductModel product SoftDeletesbelongsTo category / supplierhasMany prices;常量 STATUS_ON=1, STATUS_OFF=0
ProductPriceModel product_price belongsTo product / level;联合键 (product_id, level_id)
StoreOrderModel store_order belongsTo storehasMany items;常量 STATUS_PENDING=0 待汇总 / STATUS_SUMMARIZED=1 已汇总 / STATUS_DELIVERING=2 配送中 / STATUS_COMPLETED=3 已完成 / STATUS_CANCELLED=9 已取消
StoreOrderItemModel store_order_item belongsTo order / product
PurchaseOrderModel purchase_order belongsTo operatorSysUserModel,外键 operator_id);hasMany items;常量 STATUS_PENDING=0 待发送 / STATUS_PART_SENT=1 部分发送 / STATUS_ALL_SENT=2 全部发送 / STATUS_COMPLETED=3 已完成
PurchaseOrderItemModel purchase_order_item belongsTo purchase / product / supplierhasMany allocations
PurchaseAllocationModel purchase_allocation belongsTo purchaseItem / orderItem / store / product
ReconciliationModel reconciliation belongsTo operatorhasMany items;常量 STATUS_DRAFT=0 草稿 / STATUS_WORKING=1 对账中 / STATUS_SETTLED=2 已结算
ReconciliationItemModel reconciliation_item belongsTo recon / store / product / purchaseItem / orderItem
StatementModel statement belongsTo storehasMany items;常量 STATUS_PENDING=0 待对账 / STATUS_RECONCILED=1 已对账 / STATUS_SETTLED=2 已结算
StatementItemModel statement_item belongsTo statement / order / orderItem / product
SettlementModel settlement belongsTo recon / store / operator

3.3 配套

  • 工厂(database/factories/):Store、Product、ProductPrice、StoreOrder、StoreOrderItem、PurchaseOrder,供阶段六测试使用
  • BillNumberServiceWechatServiceExportService 骨架在本阶段一并建好(空实现 + 签名),app/Exports/resources/views/exports/ 的具体实现在阶段三随对应控制器落地
  • 导出依赖已就绪(maatwebsite/excelbarryvdh/laravel-dompdf 均已安装,SimHei 字体已注册验证,见 2.5)

四、阶段三:PC 后台 APIapp/Http/ControllersAnnoRoute

所有控制器类级 #[RequestAttribute(前缀, 权限前缀)] 不传 authGuard(默认 sys_users);方法级 authorize: 'xxx' 生成权限点 前缀.xxx

4.1 客户域 app/Http/Controllers/Customer/

CustomerLevelController#[RequestAttribute('/customer/level', 'customer.level')]$searchField = ['name' => 'like', 'status' => '=']

路由 属性 权限点 说明
GET /customer/level authorize: 'query' customer.level.query 分页列表,sort 排序
POST /customer/level authorize: 'create' customer.level.create CustomerLevelFormRequestname 必填唯一、sort、status、remark
PUT /customer/level/{id} authorize: 'update' customer.level.update 编辑
DELETE /customer/level/{id} authorize: 'delete' customer.level.delete 被 store 引用时拒绝删除
GET /customer/level/options authorize: 'query' customer.level.query 下拉选项 {id, name}(门店表单用)

StoreController/customer/storecustomer.store$searchField = ['name' => 'like', 'code' => 'like', 'level_id' => '=', 'status' => '=']$quickSearchField = ['name', 'code', 'contact', 'phone']

路由 权限点 说明
RESTquery/create/update/delete customer.store.* StoreFormRequestname、code(唯一)、level_id、contact、phone、address、payment_cycle_days(≥0)、statuswith('level') 回显等级名
GET /customer/store/options customer.store.query 下拉选项(小程序用户绑定、订单筛选用)

SupplierController/customer/suppliercustomer.supplier

路由 权限点 说明
REST customer.supplier.* SupplierFormRequestname、contact、phone、address、main_products、status
GET /customer/supplier/options customer.supplier.query 下拉选项

MiniUserController/customer/miniUsercustomer.miniUser$searchField = ['type' => '=', 'store_id' => '=', 'supplier_id' => '=', 'status' => '=']$quickSearchField = ['nickname', 'phone']

路由 属性 权限点 说明
GET /customer/miniUser authorize: 'query' customer.miniUser.query 用户列表,with('store','supplier')
PUT /customer/miniUser/{id}/bind authorize: 'bind' customer.miniUser.bind MiniUserBindRequest {type, store_id?, supplier_id?}type=1 时 store_id 必填,type=2 时 supplier_id 必填;一个门店可绑多个账号,一个账号只绑一个主体
PUT /customer/miniUser/{id}/status authorize: 'update' customer.miniUser.update 启用/停用(停用后 token 鉴权拦截:登录时检查 status)

无 create/delete:用户由小程序登录自动生成,后台只做绑定与状态管理。

NoticeController/customer/noticecustomer.notice

路由 属性 权限点 说明
GET /customer/notice authorize: 'query' customer.notice.query 通知列表
POST /customer/notice authorize: 'create' customer.notice.create NoticeFormRequestuser_id=0 为全员广播,否则指定用户;title/content/type
DELETE /customer/notice/{id} authorize: 'delete' customer.notice.delete 删除

4.2 商品域 app/Http/Controllers/Product/

ProductCategoryController#[RequestAttribute('/product/category', 'product.category')]

路由 属性 权限点 说明
GET /product/category authorize: 'query' product.category.query 树形返回(后端组装 children,前端 XinTable 树表展示),sort 排序
GET /product/category/tree authorize: 'query' product.category.query 级联选项(商品表单 category 下拉、对账筛选用)
POST /product/category authorize: 'create' product.category.create ProductCategoryFormRequestname、parent_id(防自引用成环)、sort、status
PUT /product/category/{id} authorize: 'update' product.category.update 编辑
DELETE /product/category/{id} authorize: 'delete' product.category.delete 有子分类或挂载商品时拒绝

ProductController/product/goodsproduct.goods$searchField = ['name' => 'like', 'category_id' => '=', 'supplier_id' => '=', 'status' => '=']$quickSearchField = ['name', 'spec']

路由 属性 权限点 说明
GET /product/goods authorize: 'query' product.goods.query A1 商品列表,with('category','supplier','prices.level')
POST /product/goods authorize: 'create' product.goods.create ProductFormRequestname、spec、grade、unit、category_id、supplier_id、image、sort、status、remark + prices: [{level_id, price}] 数组;事务内建商品 + 同步 product_price
PUT /product/goods/{id} authorize: 'update' product.goods.update 编辑,prices 按 level_id upsert(删除已移除的等级行)
DELETE /product/goods/{id} authorize: 'delete' product.goods.delete 软删除(连带 prices 一并删)
GET /product/goods/priceMatrix authorize: 'query' product.goods.query A2 价格矩阵:行=商品(支持 category_id/keyword 过滤),列=全部启用等级,值=price(缺失为 null)
PUT /product/goods/batchPrice authorize: 'batchPrice' product.goods.batchPrice A2 批量调价:BatchPriceRequest updates: [{product_id, level_id, price}];事务写入,写完后给受影响门店生成 Noticetype=price 提示价格变更
GET /product/goods/options authorize: 'query' product.goods.query 商品下拉 {id, name, spec, unit}(仅上架)

4.3 订单域 app/Http/Controllers/Order/

StoreOrderController#[RequestAttribute('/order/store', 'order.store')]$searchField = ['store_id' => '=', 'status' => '=', 'order_no' => 'like', 'order_date' => 'betweenDate']

路由 属性 权限点 说明
GET /order/store authorize: 'query' order.store.query 订单列表,with('store')order_date 倒序
GET /order/store/{id} authorize: 'query' order.store.query 详情:订单头 + items(含商品快照)
PUT /order/store/{id}/status authorize: 'update' order.store.update 状态流转 {status},按常量校验合法路径(待汇总→配送中→完成;待汇总可取消);流转时可选写 Notice 通知门店
GET /order/store/summary authorize: 'query' order.store.query 待汇总预览:聚合 status=PENDING 的订单明细按 product_id group,输出 {product_id, product_name, spec, unit, total_quantity, store_count},供生成采购单前确认

订单只读 + 状态管理:创建/取消在小程序端(阶段四),后台不提供增删。

4.4 采购域 app/Http/Controllers/Purchase/

PurchaseOrderController#[RequestAttribute('/purchase/order', 'purchase.order')]$searchField = ['status' => '=', 'purchase_no' => 'like', 'purchase_date' => 'betweenDate']

路由 属性 权限点 说明
GET /purchase/order authorize: 'query' purchase.order.query 列表,with('operator')
GET /purchase/order/{id} authorize: 'query' purchase.order.query 详情:头 + itemswith supplier+ allocations
PUT /purchase/order/{id} authorize: 'update' purchase.order.update C4 修改头信息(purchase_date、remark
POST /purchase/order/generate authorize: 'generate' purchase.order.generate C1 核心PurchaseGenerateService::generate($date, $operatorId),见下
GET /purchase/order/{id}/export authorize: 'export' purchase.order.export C2/C3 ?type=all|category&format=xlsx|pdf:all=全品类按分类 sort 排序;category=仅蔬果分类。ExportService::download('purchase', ...) 输出 blob
PUT /purchase/order/item/{id} authorize: 'update' purchase.order.update C4 明细修改:PurchaseItemUpdateRequestproduct_name/spec、weight、price、quantity);amount 后端重算 = weight>0 ? weight×price : quantity×price;同步回写 purchase_order 汇总(Σ total_weight / actual_amount
PUT /purchase/order/item/{id}/send authorize: 'send' purchase.order.send C5/C6is_sent=1, sent_at=now;联动采购单状态——全量明细已发送→ALL_SENT,否则 PART_SENT
POST /purchase/order/{id}/allocate authorize: 'allocate' purchase.order.allocate D3 核心PurchaseAllocateService::allocate($purchase),见下
GET /purchase/order/{id}/allocation authorize: 'query' purchase.order.query 分摊结果:按门店、按商品两个聚合维度返回

PurchaseGenerateService::generate 逻辑(事务):

  1. 查询 order_date = $datestatus = STATUS_PENDING 的所有订单(无则报错「当日无待汇总订单」)
  2. 展开 items 按 (product_id, supplier_id) 聚合:Σquantity;快照 product_name / product_spec估算单价取该商品最低等级价product_price MIN),amount = quantity × 估算单价
  3. 创建 purchase_orderpurchase_no = BillNumberService::make('PO')、purchase_date、estimate_amount = Σitems.amount、operator_id、status = STATUS_PENDING
  4. 创建 items(按 分类 sort → 商品 sort 排序写入 sort 字段)
  5. 批量回写源订单 status = STATUS_SUMMARIZED
  6. 幂等防护:步骤 1 的筛选条件天然排除已汇总订单;同一秒并发用 DB 事务 + 订单行锁(lockForUpdate)防重

PurchaseAllocateService::allocate 逻辑(事务):

  1. 采购单须已录入实际金额(item.amount 已修改),否则拒绝
  2. 对每个采购明细,溯源当日该商品的所有订货明细(store_order_item.product_id = item.product_id 且订单 order_date = purchase_date 且已汇总)
  3. 按订货数量比例分摊实际金额:allocation.amount = bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)尾差修正——最后一个(或最大额)明细承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒)
  4. 同步写入 quantity / weight(按比例)与 store_id / order_item_id
  5. 重复分摊:先删旧 allocation 再重建(幂等)

4.5 对账域 app/Http/Controllers/Recon/

ReconciliationController#[RequestAttribute('/recon/list', 'recon.list')]$searchField = ['status' => '=', 'category_id' => '=', 'supplier_id' => '=', 'title' => 'like', 'period_start' => 'date']

路由 属性 权限点 说明
GET /recon/list authorize: 'query' recon.list.query D1 品类 / D2 供应商筛选条件落在表字段上
POST /recon/list authorize: 'create' recon.list.create ReconciliationFormRequesttitle、period_start、period_end、category_id?、supplier_id?recon_no = RC…,status=DRAFT
PUT /recon/list/{id} authorize: 'update' recon.list.update 编辑(仅 DRAFT/WORKING
DELETE /recon/list/{id} authorize: 'delete' recon.list.delete 仅 DRAFT 可删,连带 items
POST /recon/list/{id}/build authorize: 'build' recon.list.build ReconciliationBuildService::build($recon):按周期 + 品类 + 供应商拉取 purchase_order_item(含其 allocations),生成 reconciliation_item——published_amount=订货金额(溯源 order_item.amount)、actual_amount=分摊金额、diff=publishactual,冗余 product_name、store_id;汇总写回头的 publish/actual/diff_amountstatus→WORKING;可重复 build(先清后建)
PUT /recon/item/{id} authorize: 'item.update' recon.item.item.update D4 修改订货量/称重/数量/金额/商品信息,自动重算本行 diff + 汇总头
PUT /recon/item/{id}/toggle authorize: 'item.update' recon.item.item.update D8 is_reconciled 翻转
PUT /recon/item/{id}/remark authorize: 'item.update' recon.item.item.update D6 单品级门店备注 store_remark
GET /recon/list/{id}/diff authorize: 'query' recon.list.query D5 差额对比视图:{by_store: [{store_id, store_name, publish, actual, diff}], by_product: [...]} + 合计行
POST /recon/list/{id}/settle authorize: 'settle' recon.list.settle D9:按门店聚合 items 生成 settlement 记录(settlement_no = JS…、total/actual/diff),status→SETTLED;回框统计表规则待业务确认,本次仅预留结构

StatementController/recon/statementrecon.statementquerywith storeperiod 筛选)/ GET {id} 详情(后台视角,只读)

SettlementController/recon/settlementrecon.settlement

路由 属性 权限点 说明
GET /recon/settlement authorize: 'query' recon.settlement.query 列表 with('store','recon')
GET /recon/settlement/{id} authorize: 'query' recon.settlement.query 详情
GET /recon/settlement/{id}/download authorize: 'download' recon.settlement.download D10 ?format=xlsx|pdfExportService::download('settlement', ...) 返回 blob;成功后回写 file_path 存档标记

五、阶段四:小程序 APIapp/Http/Controllers/Mini

类级统一 #[RequestAttribute('/mini', 'mini', authGuard: 'users')]abilities 前缀 mini,登录接口 authorize: false,其余方法 authorize: true(只校验持有 mini ability,不做细粒度权限点)。 门店端接口前置校验 type = TYPE_STORE && store_id > 0(抽公共 ensureStoreBound() 辅助方法);供应商端同理。

5.1 AuthController

路由 属性 说明
POST /mini/auth/login authorize: false {code} → code2Session → firstOrCreate(openid)status 停用则拒绝)→ createToken('mini', ['mini']) → 返回 {token, user: {id, nickname, avatar, type, store, supplier}},更新 last_login_at
POST /mini/auth/phone authorize: true {phoneCode} → 换手机号绑定 phone → 按 phone 自动匹配门店/供应商(见 2.3)→ 返回更新后的 user
GET /mini/auth/info authorize: true 当前用户 + 门店信息(含客户等级,全局价格体系依据)/ 供应商信息

5.2 门店端

路由 方法 说明
/mini/product/categories GET 分类树(仅含上架商品的分类)
/mini/product/list GET ?category_id=&keyword=&page=价格 = product_price where level_id = 当前门店等级;未绑等级门店返回错误提示
/mini/order POST MiniOrderRequest {items: [{product_id, quantity}]};事务:逐行取等级价快照(name/spec/unit/price),服务端重算 amount 与 total,不接受前端金额order_no = SO…,status = PENDING
/mini/order GET 历史订单:当前 store_id 强制过滤,?status=&page=
/mini/order/{id} GET 详情(校验归属)
/mini/order/{id}/cancel PUT 仅 STATUS_PENDING 可取消
/mini/order/summary GET ?period=day|week|month:按周期聚合金额/数量,返回分组列表 + 下钻明细接口参数
/mini/statement GET 对账单列表(当前门店)
/mini/statement/generate POST {period_start, period_end}StatementGenerateService——拉周期内订单明细,快照当前 payment_cycle_dayssettlement_date = period_end + cycle 天statement_no = ST…
/mini/statement/{id} GET 详情(含单品对账状态标识)
/mini/statement/{id}/export GET ?format=xlsx|pdfExportService::download('statement', ...)blob
/mini/store/info GET 门店详情(编辑回显;name/code/payment_cycle_days 只读)
/mini/store/info PUT 修改门店信息:仅 {contact, phone, address} 白名单更新;回款周期由后台维护
/mini/notice GET 本人通知 + 全员广播(user_id in [0, 当前id]),分页 + unread_count
/mini/notice/{id}/read PUT 标记已读 + read_at

5.3 供应商端

路由 方法 说明
/mini/supplier/purchases GET 收到的采购单:含本供应商 is_sent=1 明细的采购单(去重)
/mini/supplier/purchases/{id} GET 明细:仅本供应商的明细行
/mini/supplier/purchases/{id}/confirm PUT 确认接单(确认态记录方式见「待确认 #7」)

六、阶段五:PC 前端页面 + 菜单(硬编码中文,无 i18n)

6.1 页面清单(web/pages/,全部硬编码中文文案)

XinTable 标准 CRUD 只需 api + accessName + columns + rowKey 四个 props,增删改查请求自动封装,按钮自动套 <AuthButton>

页面 组件形态 关键点
product/category.tsx XinTable 树表 api="/product/category"columnsname / sort / status(Tag) / 操作;表单 parent_id 用 treeSelect 拉 /product/category/tree
product/goods.tsx XinTable + ModalForm + 两个抽屉 columnsname / spec / grade / unit / category(render 名) / supplier / status(Switch 样式 Tag) / sort;表单内嵌 Form.List 按等级动态价格行(等级选项拉 /customer/level/options);工具栏自定义按钮「价格矩阵」(抽屉:行商品 × 列等级可编辑 → 调 batchPrice
customer/level.tsx XinTable name / sort / status / remark
customer/store.tsx XinTable name / code / level(select 拉 options) / contact / phone / payment_cycle_days(InputNumber) / status
customer/supplier.tsx XinTable name / contact / phone / main_products / status
customer/mini-user.tsx XinTable + 绑定 Modal 列:nickname / phone / type(Tag) / store 或 supplier 名 / status / last_login_at;行内「绑定」按钮弹 Modal:type 单选 + 门店/供应商 select 联动 → bindMiniUser();「停用/启用」→ toggleMiniUserStatus()
customer/notice.tsx XinTable title / type(Tag) / user0 显示「全员」)/ is_read / created_at;表单 user_id 留空=广播
order/store.tsx XinTable + 详情 Drawer 列:order_no / store / order_date / total_amount / status(Tag 按常量映射);搜索栏 store 下拉 + 日期范围 + 状态;行内「详情」抽屉展示 items 表格 + 状态流转按钮(按当前状态显示可用操作)
purchase/order.tsx XinTable + 生成 Modal + 详情 Drawer 工具栏「生成采购单」按钮(日期选择 → generatePurchase());详情抽屉 Tab:明细(行内编辑 weight/price → updatePurchaseItem()、发送按钮 → sendPurchaseItem()/ 分摊(「执行分摊」按钮 → allocatePurchase(),结果表);头部「导出」下拉:全品类 / 蔬果分类 × Excel / PDF 四个选项 → exportPurchase(id, type, format)
recon/list.tsx XinTable + 对账工作台 Drawer 列表 + 「生成明细」按钮(buildRecon());工作台抽屉 Tab:明细编辑(D4 行内编辑 → updateReconItem()、D6 备注 → remarkReconItem()、D8 对账标记开关 → toggleReconItem()/ 差额对比(getReconDiff() 双维度表);「生成结算表」按钮(settleRecon()
recon/statement.tsx XinTable statement_no / store / period / total_amount / settlement_date / status;详情抽屉只读
recon/settlement.tsx XinTable settlement_no / store / total / actual / diff / status;行内「下载」下拉(Excel / PDF)→ downloadSettlement(id, format)

6.2 前端 API 封装(web/api/,仅封装 XinTable 默认 REST 之外的自定义接口)

XinTable 依据 api prop 自动完成列表/增/改/删四个标准请求,标准 CRUD 无需手写封装。以下只列自定义动作:

文件 函数 请求
api/customer/level.ts getLevelOptions() GET /customer/level/options
api/customer/store.ts getStoreOptions() GET /customer/store/options
api/customer/supplier.ts getSupplierOptions() GET /customer/supplier/options
api/customer/miniUser.ts bindMiniUser(id, {type, store_id?, supplier_id?}) / toggleMiniUserStatus(id, status) PUT /customer/miniUser/{id}/bind/status
api/product/category.ts getCategoryTree() GET /product/category/tree
api/product/goods.ts getPriceMatrix(params) / batchPrice({updates}) / getProductOptions() GET /product/goods/priceMatrix、PUT /product/goods/batchPrice、GET /product/goods/options
api/order/store.ts getStoreOrder(id) / updateOrderStatus(id, status) / getOrderSummary(params) GET /order/store/{id}、PUT /order/store/{id}/status、GET /order/store/summary
api/purchase/order.ts generatePurchase({purchase_date}) / exportPurchase(id, type, format) / updatePurchaseItem(id, data) / sendPurchaseItem(id) / allocatePurchase(id) / getAllocation(id) POST /purchase/order/generate、GET /purchase/order/{id}/export?type=&format=xlsx|pdfblob)、PUT /purchase/order/item/{id}、PUT /purchase/order/item/{id}/send、POST /purchase/order/{id}/allocate、GET /purchase/order/{id}/allocation
api/recon/list.ts buildRecon(id) / updateReconItem(id, data) / toggleReconItem(id) / remarkReconItem(id, remark) / getReconDiff(id) / settleRecon(id) POST /recon/list/{id}/build、PUT /recon/item/{id}/toggle/remark、GET /recon/list/{id}/diff、POST /recon/list/{id}/settle
api/recon/settlement.ts downloadSettlement(id, format) GET /recon/settlement/{id}/download?format=xlsx|pdfblob
api/common/download.ts downloadBlob(url, params, fallbackName) 公共下载工具:封装 blob 请求 + 触发保存(见下载约定),各导出函数复用它

下载约定api/common/download.ts 统一实现——createAxios({ url, method: 'get', params, responseType: 'blob' })blob 错误兜底(响应是 JSON 错误而非文件时,blob.text() 解析出 msg 走 antd message 提示);成功后 URL.createObjectURL + <a download> 触发保存,文件名优先解析响应头 Content-Dispositionfilename*=UTF-8'' RFC 5987 解码),兜底用调用方传入的 fallbackName(单号拼接)。

6.3 Domain 类型(web/domain/

iCustomerLevel.tsiStore.tsiSupplier.tsiMiniUser.tsiNotice.tsiProduct.ts(含 prices: {level_id, price}[])、iProductCategory.tsiStoreOrder.ts(含 items)、iPurchaseOrder.ts(含 items / allocations)、iReconciliation.ts(含 items / diff 视图类型)、iStatement.tsiSettlement.ts —— 与后端返回结构一一对应,状态字段导出 const STATUS_MAP 常量供 render 使用。

6.4 菜单权限 Seederdatabase/seeders/ProcurementSeeder.php

沿用 SysUserSeeder 的嵌套创建结构(父 menu → 子 route → 孙 rule)。local 字段一律留空,name 直接写中文layout 自动回退显示 name):

商品中心(menu, icon: ShoppingOutlined
├── 分类管理(route, key: product.category, path: /product/category
│   └── rule: query / create / update / delete
└── 商品列表(route, key: product.goods, path: /product/goods
    └── rule: query / create / update / delete / batchPrice
客户管理(menu, icon: ShopOutlined
├── 门店管理(customer.store → /customer/store: query / create / update / delete
├── 客户等级(customer.level → /customer/level: query / create / update / delete
├── 供应商(customer.supplier → /customer/supplier: query / create / update / delete
├── 小程序用户(customer.miniUser → /customer/mini-user: query / update / bind
└── 通知管理(customer.notice → /customer/notice: query / create / delete
订货管理(menu
└── 门店订单(order.store → /order/store: query / update
采购管理(menu
└── 采购单(purchase.order → /purchase/order: query / update / generate / export / send / allocate
对账管理(menu
├── 财务对账(recon.list → /recon/list: query / create / update / delete / build / item.update / settle
├── 门店对账单(recon.statement → /recon/statement: query
└── 结算表(recon.settlement → /recon/settlement: query / download

执行:php artisan db:seed --class=ProcurementSeeder(种子内对 admin 角色自动授权)。


七、阶段六:测试(PHPUnit Feature Teststests/Feature/

测试 覆盖点
ProductPriceTest 等级价格匹配、批量调价事务、调价通知生成
StoreOrderTest 下单快照等级价、服务端重算总价(前端传金额被忽略)、取消限制、门店数据隔离
PurchaseGenerateTest 多门店订单聚合正确性、订单状态回写、无订单/重复生成防护
AllocationTest 金额守恒(Σallocation.amount === item.actual_amount 含尾差修正)、按订货比例正确性、幂等重跑
ReconciliationTest 明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 状态标记
StatementTest 回款周期快照 → settlement_date = period_end + cycle 计算、门店仅能生成/查看自身对账单
MiniAuthTest code2Session mock → 签发 token、手机号绑定自动匹配门店、停用账号拒绝登录、后台 token 访问 /mini 被拦截(跨端隔离)
ExportTest 采购单导出 xlsx 返回正确 Content-Type 且蔬果分类过滤生效、PDF 返回 application/pdf、中文文件名响应头 RFC 5987 编码、format 参数非法时报错、无权限点拦截

用工厂造数;微信 HTTP 调用在 WechatService 中抽接口方法,测试里 mock/fake Http facade。


八、核心业务数据流

门店下单(store_order / _item,快照等级价,status=0待汇总)
  └─► 采购员生成采购单(purchase_order / _item,按商品+供应商聚合,估算单价=最低等级价)
        │   └─ 门店订单 status=1已汇总
        ├─► 发送供应商(item.is_sent=1 + sent_at,采购单状态 PART/ALL_SENT)
        ├─► 实际采购录入(item.weight / price → amount 后端重算 → 头 actual_amount
        └─► 金额分摊(purchase_allocation:按订货比例摊到门店/单品,尾差修正守恒)
              └─► 财务对账(reconciliation / _item:公布 vs 实际 vs 差额,可修改/备注/标记)
                    └─► 结算表(settlement,导出存档)
门店侧:statement / _item 按周期自助生成(快照回款周期 → settlement_date),可导出

金额守恒校验点:采购单 Σitem.amount = actual_amount;分摊 Σallocation.amount = item.amount;对账 diff = publish actual。


九、待确认 / 需批准事项

# 事项 影响
1 已解决maatwebsite/excel ^3.1 已安装(2026-07-23 C2/C3/D10/对账单导出
2 微信小程序 AppID/SecretWECHAT_MINI_APPID / WECHAT_MINI_SECRET 微信登录、手机号授权
3 D7 特殊业务(周转柜/周转托盘/调货/售后/物流)及「回框统计表」规则 数据库需补充表,暂预留
4 已解决barryvdh/laravel-dompdf ^3.1 已安装,SimHei 中文字体已注册并验证中文 PDF 生成;Excel/PDF 双格式全支持 对账单/结算表导出格式
5 采购单「微信快捷发送供应商」确认形态:后台导出文件人工转发 vs 小程序订阅消息推送 C5 实现方式(当前计划:后台标记 + 供应商小程序拉取)
6 新用户注册后绑定门店的策略:当前为「手机号自动匹配,不中则 type=0 待后台人工绑定」——是否认可 小程序登录流程
7 供应商确认接单的状态落库purchase_order_item 暂无确认字段,需批准给该表补 supplier_confirmed_at timestamp nullable(或暂记 remark 供应商端确认接口
8 SimHei 字体随仓库分发(resources/fonts/simhei.ttf,9.7MB)——授权上可替换为开源字体(如思源黑体 SourceHanSansSC-Regular.otf,需验证 DomPDF 对 OTF 的支持) PDF 字体合规

十、实施顺序与工作量预估

顺序 内容 预估
1 阶段二 模型层(app/Models 18 个模型 + 工厂 + 两个 Service 骨架) 0.5 天
2 阶段三 后台 APICustomer → Product → Order → Purchase → Recon,每域完成后顺手写对应 Feature Test 4 天
3 阶段四 小程序 API(含 WechatService 与登录) 2 天
4 阶段五 前端页面(12 页)+ api/domain 封装 + 菜单 Seeder 3.5 天(去掉 i18n 后缩减 0.5 天)
5 阶段六 测试补齐与联调 1.5 天

每完成一个后端域即联调对应前端页面。