****# 订货采购系统 · 开发计划 > 版本:V2.0(2026-07-23) > 依据:《项目需求规划书.md》V1.0 > 技术栈:Laravel 13 + XinAdmin(AnnoRoute / 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` guard(provider 已指向 `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-23,migrate:fresh 已执行) | | 二 | 模型层(UserModel 扩展 + 17 个新模型,含关系/常量/工厂) | ✅ 已完成(2026-07-23,18 模型 + 9 工厂 + 3 Service 骨架,104 项自检通过) | | 三 | PC 后台 API(`app/Http/Controllers` 下 5 个业务域 + FormRequest + Service) | ✅ 已完成(2026-07-23,14 控制器 + 9 FormRequest + 6 Service/Export,44 路由与权限点验证通过;对账明细操作拆为独立 ReconItemController 以匹配 recon.item.item.update 权限点) | | 四 | 小程序 API(`app/Http/Controllers/Mini`,微信登录 + 门店端 + 供应商端) | ✅ 已完成(2026-07-23,7 控制器 + WechatService 完整实现 + 20 路由验证通过;#7 已批准:purchase_order_item 补 supplier_confirmed_at) | | 五 | PC 前端页面 + 菜单权限 Seeder(硬编码中文,无 i18n) | ✅ 已完成(2026-07-23,12 页面 + 11 API 封装 + 12 domain 类型 + ProcurementSeeder 62 节点已入库授权,tsc/vite build 通过) | | 六 | PHPUnit 功能测试 | ✅ 已完成(2026-07-23,8 测试类 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 token,abilities = `['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::code2Session(appid + 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,已就绪) **依赖(已安装并验证)** | 用途 | 包 | 版本 | 状态 | |------|----|----|------| | Excel(xlsx/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 | SoftDeletes;belongsTo level;hasMany orders / users;`payment_cycle_days` 影响对账单 | | SupplierModel | supplier | SoftDeletes;hasMany products / purchaseItems / users | | NoticeModel | notice | belongsTo user;casts `data` => array;常量 `TYPE_ORDER/TYPE_PRICE/TYPE_SYSTEM` | | ProductCategoryModel | product_category | parent/children 自关联;提供静态 `getTreeData()`(分类树/级联选项复用) | | ProductModel | product | SoftDeletes;belongsTo category / supplier;hasMany prices;常量 `STATUS_ON=1, STATUS_OFF=0` | | ProductPriceModel | product_price | belongsTo product / level;联合键 (product_id, level_id) | | StoreOrderModel | store_order | belongsTo store;hasMany 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 operator(SysUserModel,外键 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 / supplier;hasMany allocations | | PurchaseAllocationModel | purchase_allocation | belongsTo purchaseItem / orderItem / store / product | | ReconciliationModel | reconciliation | belongsTo operator;hasMany items;常量 `STATUS_DRAFT=0 草稿 / STATUS_WORKING=1 对账中 / STATUS_SETTLED=2 已结算` | | ReconciliationItemModel | reconciliation_item | belongsTo recon / store / product / purchaseItem / orderItem | | StatementModel | statement | belongsTo store;hasMany 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 后台 API(app/Http/Controllers,AnnoRoute) > 所有控制器类级 `#[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 | CustomerLevelFormRequest(name 必填唯一、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']` | 路由 | 权限点 | 说明 | |------|--------|------| | REST(query/create/update/delete) | customer.store.* | StoreFormRequest:name、code(唯一)、level_id、contact、phone、address、payment_cycle_days(≥0)、status;with('level') 回显等级名 | | `GET /customer/store/options` | customer.store.query | 下拉选项(小程序用户绑定、订单筛选用) | **SupplierController** — `/customer/supplier`,`customer.supplier` | 路由 | 权限点 | 说明 | |------|--------|------| | REST | customer.supplier.* | SupplierFormRequest:name、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 | NoticeFormRequest:user_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 | ProductCategoryFormRequest:name、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 | ProductFormRequest:name、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}]`;事务写入,**写完后给受影响门店生成 Notice(type=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 | 详情:头 + items(with 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 明细修改:PurchaseItemUpdateRequest(product_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 | ReconciliationFormRequest:title、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=publish−actual,冗余 product_name、store_id;汇总写回头的 publish/actual/diff_amount;status→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 store,period 筛选)/ `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` 存档标记 | --- ## 五、阶段四:小程序 API(app/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_days,settlement_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/paymentCycle` | PUT | `{payment_cycle_days}`(≥0,无上限) | | `/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,增删改查请求自动封装,按钮自动套 ``。 | 页面 | 组件形态 | 关键点 | |------|----------|--------| | `product/category.tsx` | XinTable 树表 | `api="/product/category"`,columns:name / sort / status(Tag) / 操作;表单 parent_id 用 treeSelect 拉 `/product/category/tree` | | `product/goods.tsx` | XinTable + ModalForm + 两个抽屉 | columns:name / 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) / user(0 显示「全员」)/ 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` + `` 触发保存,文件名优先解析响应头 `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 菜单权限 Seeder(database/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 Tests,tests/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 | 阶段三 后台 API(Customer → Product → Order → Purchase → Recon,每域完成后顺手写对应 Feature Test) | 4 天 | | 3 | 阶段四 小程序 API(含 WechatService 与登录) | 2 天 | | 4 | 阶段五 前端页面(12 页)+ api/domain 封装 + 菜单 Seeder | 3.5 天(去掉 i18n 后缩减 0.5 天) | | 5 | 阶段六 测试补齐与联调 | 1.5 天 | 每完成一个后端域即联调对应前端页面。