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

518 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
****# 订货采购系统 · 开发计划
> 版本: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/Models`、`app/Http/Controllers`、`app/Http/Requests`、`app/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 已注册进 DomPDF`resources/fonts/simhei.ttf``AppServiceProvider` 启动时幂等注册,已验证中文 PDF 生成);所有导出接口支持 `?format=xlsx|pdf`,详见 2.5
---
## 一、进度总览
| 阶段 | 内容 | 状态 |
|------|------|------|
| 一 | 数据库迁移(user 表扩展 + 16 张业务表,共 17 张) | ✅ 已完成(2026-07-23migrate:fresh 已执行) |
| 二 | 模型层(UserModel 扩展 + 17 个新模型,含关系/常量/工厂) | ✅ 已完成(2026-07-2318 模型 + 9 工厂 + 3 Service 骨架,104 项自检通过) |
| 三 | PC 后台 API`app/Http/Controllers` 下 5 个业务域 + FormRequest + Service | ✅ 已完成(2026-07-2314 控制器 + 9 FormRequest + 6 Service/Export44 路由与权限点验证通过;对账明细操作拆为独立 ReconItemController 以匹配 recon.item.item.update 权限点) |
| 四 | 小程序 API`app/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`
- `AuthGuardMiddleware``tokenable_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.x**`w7corp/easywechat``MiniApp\Application` 封装,code2Session / getPhoneNumber 走 SDK `Utils`access_token 由 SDK 自动管理);测试通过 `WechatService::setHttpClient()` 注入 Symfony `MockHttpClient` 拦截微信调用,`WechatService` 在 `AppServiceProvider` 注册为单例以保证注入生效。
### 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`**;金额运算用 `bcmath`bcadd/bcmul),禁止浮点直算
- **状态字段一律类常量**(如 `StoreOrderModel::STATUS_PENDING`),控制器/前端 render 均引用常量映射,禁止魔术数字
- **前端文案硬编码中文**:页面 `title`、表格列名、按钮文字直接写字面量;错误提示走后端返回的 `msg`(后端校验消息也直接写中文,不用 `__()`
### 2.5 导出方案(Excel + PDF,已就绪)
**依赖(已安装并验证)**
| 用途 | 包 | 版本 | 状态 |
|------|----|----|------|
| Excelxlsx/csv | `maatwebsite/excel`PhpSpreadsheet | ^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` |
**统一入口**
```php
// 控制器只调一行,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`**移除不存在的 `mobile`**(user 表无此列,系历史遗留) |
| casts | `last_login_at` => `datetime` |
| 常量 | `TYPE_PENDING=0, TYPE_STORE=1, TYPE_SUPPLIER=2``STATUS_NORMAL=1, STATUS_DISABLED=0` |
| 关系 | `store()` belongsTo StoreModel`supplier()` belongsTo SupplierModel`notices()` hasMany NoticeModel(外键 `user_id` |
### 3.2 新模型清单
| 模型 | 表 | 要点 |
|------|----|------|
| CustomerLevelModel | customer_level | hasMany stores |
| StoreModel | store | SoftDeletesbelongsTo levelhasMany orders / users`payment_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,供阶段六测试使用
- `BillNumberService``WechatService``ExportService` 骨架在本阶段一并建好(空实现 + 签名),`app/Exports/``resources/views/exports/` 的具体实现在阶段三随对应控制器落地
- 导出依赖已就绪(`maatwebsite/excel``barryvdh/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/store``customer.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/supplier``customer.supplier`
| 路由 | 权限点 | 说明 |
|------|--------|------|
| REST | customer.supplier.* | SupplierFormRequestname、contact、phone、address、main_products、status |
| `GET /customer/supplier/options` | customer.supplier.query | 下拉选项 |
**MiniUserController**`/customer/miniUser``customer.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/notice``customer.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/goods``product.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/C6`is_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 = $date``status = STATUS_PENDING` 的所有订单(无则报错「当日无待汇总订单」)
2. 展开 items 按 `(product_id, supplier_id)` 聚合:Σquantity;快照 product_name / product_spec**估算单价取该商品最低等级价**product_price MIN),amount = quantity × 估算单价
3. 创建 purchase_order`purchase_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/statement``recon.statement``query`with storeperiod 筛选)/ `GET {id}` 详情(后台视角,只读)
**SettlementController**`/recon/settlement``recon.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\|pdf``ExportService::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\|pdf``ExportService::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\|pdf`blob)、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\|pdf`blob |
| `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-Disposition``filename*=UTF-8''` RFC 5987 解码),兜底用调用方传入的 `fallbackName`(单号拼接)。
### 6.3 Domain 类型(web/domain/
`iCustomerLevel.ts``iStore.ts``iSupplier.ts``iMiniUser.ts``iNotice.ts``iProduct.ts`(含 `prices: {level_id, price}[]`)、`iProductCategory.ts``iStoreOrder.ts`(含 items)、`iPurchaseOrder.ts`(含 items / allocations)、`iReconciliation.ts`(含 items / diff 视图类型)、`iStatement.ts``iSettlement.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/Secret`WECHAT_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 天 |
每完成一个后端域即联调对应前端页面。