first commit

This commit is contained in:
liu
2026-07-23 12:31:37 +08:00
parent 760c9ba8b6
commit 00a0938a1b
14 changed files with 1442 additions and 3 deletions
+1
View File
@@ -7,6 +7,7 @@
/node_modules
/public/storage
/storage/*.key
/storage/fonts
/.claude
hot
+37
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
use Illuminate\Support\ServiceProvider;
use Modules\AnnoRoute\AnnoRoute;
@@ -25,5 +26,41 @@ class AppServiceProvider extends ServiceProvider
{
// 注册路由
$annoRoute->register(app_path('Http/Controllers'));
// 注册 DomPDF 中文字体(黑体),PDF 导出使用 font-family: SimHei
$this->registerDompdfChineseFont();
}
/**
* 注册 DomPDF 中文字体(幂等:已注册则跳过)
*/
private function registerDompdfChineseFont(): void
{
$fontFile = resource_path('fonts/simhei.ttf');
if (! file_exists($fontFile)) {
return;
}
$fontMetrics = Pdf::getDomPDF()->getFontMetrics();
if ($fontMetrics->getFamily('SimHei')) {
return;
}
if (! is_dir(storage_path('fonts'))) {
mkdir(storage_path('fonts'), 0755, true);
}
$variants = [
['weight' => 'normal', 'style' => 'normal'],
['weight' => 'bold', 'style' => 'normal'],
['weight' => 'normal', 'style' => 'italic'],
['weight' => 'bold', 'style' => 'italic'],
];
foreach ($variants as $variant) {
$fontMetrics->registerFont(
['family' => 'SimHei', 'weight' => $variant['weight'], 'style' => $variant['style']],
$fontFile
);
}
}
}
+2
View File
@@ -10,10 +10,12 @@
"ext-curl": "*",
"ext-pdo": "*",
"ext-redis": "*",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/ai": "^0.7.0",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"maatwebsite/excel": "^3.1",
"predis/predis": "2.0"
},
"require-dev": {
+301
View File
@@ -0,0 +1,301 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| Set some default values. It is possible to add all defines that can be set
| in dompdf_config.inc.php. You can also override the entire config file.
|
*/
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'public_path' => null, // Override the public path if needed
/*
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £.
*/
'convert_entities' => true,
'options' => [
/**
* The location of the DOMPDF font directory
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
* *Please note the trailing slash.*
*
* Notes regarding fonts:
* Additional .afm font metrics can be added by executing load_font.php from command line.
*
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
* increase file size unless font subsetting is enabled. Before embedding a font please
* review your rights under the font license.
*
* Any font specification in the source HTML is translated to the closest font available
* in the font directory.
*
* The pdf standard "Base 14 fonts" are:
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts'),
/**
* The location of a temporary directory.
*
* The directory specified must be writeable by the webserver process.
* The temporary directory is required to download remote images and when
* using the PDFLib back end.
*/
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
* subdirectory of this directory. DO NOT set it to '/' since this could
* allow an attacker to use dompdf to read any files on the server. This
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
/**
* Protocol whitelist
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* @var array
*/
'allowed_protocols' => [
'data://' => ['rules' => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* Operational artifact (log files, temporary files) path validation
*/
'artifactPathValidation' => null,
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
*/
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files.
* {@link * Canvas_Factory} ultimately determines which rendering class to
* instantiate based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
* capabilities for dompdf, however additional features (e.g. object,
* image and font support, etc.) differ between backends. Please see
* {@link PDFLib_Adapter} for more information on the PDFLib backend
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
* on CPDF. Also see the documentation for each backend at the links
* below.
*
* The GD rendering backend is a little different than PDFLib and
* CPDF. Several features of CPDF and PDFLib are not supported or do
* not make any sense when creating image files. For example,
* multiple pages are not supported, nor are PDF 'objects'. Have a
* look at {@link GD_Adapter} for more information. GD support is
* experimental, so use it at your own risk.
*
* @link http://www.pdflib.com
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
'pdf_backend' => 'CPDF',
/**
* html target media view which should be rendered into pdf.
* List of types and parsing rules for future extensions:
* http://www.w3.org/TR/REC-html40/types.html
* screen, tty, tv, projection, handheld, print, braille, aural, all
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
* Note, even though the generated pdf file is intended for print output,
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
'default_media_type' => 'screen',
/**
* The default paper size.
*
* North America standard is "letter"; other countries generally "a4"
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
'default_font' => 'serif',
/**
* Image DPI setting
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
* image's width & height style attributes (i.e. if the image's native
* width is 600 pixels and you specify the image's width as 72 points,
* the image will have a DPI of 600 in the rendered PDF. The DPI of
* background images can not be overridden and is controlled entirely
* via this parameter.
*
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
* If a size in html is given as px (or without unit as image size),
* this tells the corresponding size in pt.
* This adjusts the relative sizes to be similar to the rendering of the
* html page in a reference browser.
*
* In pdf, always 1 pt = 1/72 inch
*
* Rendering resolution of various browsers in px per inch:
* Windows Firefox and Internet Explorer:
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
* Linux Firefox:
* about:config *resolution: Default:96
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
*
* Take care about extra font/image zoom factor of browser.
*
* In images, <img> size in pixel attribute, img css style, are overriding
* the real image dimension in px for rendering.
*
* @var int
*/
'dpi' => 96,
/**
* Enable embedded PHP
*
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
* within <script type="text/php"> ... </script> tags.
*
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
* is a security risk.
* Embedded scripts are run with the same level of system access available to dompdf.
* Set this option to false (recommended) if you wish to process untrusted documents.
* This setting may increase the risk of system exploit.
* Do not change this settings without understanding the consequences.
* Additional documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_php' => false,
/**
* Enable inline JavaScript
*
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
* not browser-based JavaScript executed by Dompdf.
*
* @var bool
*/
'enable_javascript' => true,
/**
* Enable remote file access
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
*
* ==== IMPORTANT ====
* This can be a security risk, in particular in combination with isPhpEnabled and
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
* This allows anonymous users to download legally doubtful internet content which on
* tracing back appears to being downloaded by your server, or allows malicious php code
* in remote html pages to be executed by your server with your account privileges.
*
* This setting may increase the risk of system exploit. Do not change
* this settings without understanding the consequences. Additional
* documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_remote' => false,
/**
* List of allowed remote hosts
*
* Each value of the array must be a valid hostname.
*
* This will be used to filter which resources can be loaded in combination with
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
*
* Leave to NULL to allow any remote host.
*
* @var array|null
*/
'allowed_remote_hosts' => null,
/**
* A ratio applied to the fonts height to be more like browsers' line height
*/
'font_height_ratio' => 1.1,
/**
* Use the HTML5 Lib parser
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5_parser' => true,
],
];
@@ -14,14 +14,25 @@ return new class extends Migration
if (! Schema::hasTable('user')) {
Schema::create('user', function (Blueprint $table) {
$table->increments('id')->comment('用户ID');
$table->string('username', 20)->unique()->comment('用户名');
$table->string('password', 100)->comment('密码');
$table->string('username', 20)->nullable()->unique()->comment('用户名(微信注册用户可为空)');
$table->string('password', 100)->nullable()->comment('密码(微信注册用户可为空)');
$table->string('nickname', 20)->default('')->comment('昵称');
$table->string('email', 50)->default('')->comment('邮箱');
$table->timestamp('email_verified_at')->nullable();
// 小程序用户扩展字段(微信授权登录,自动识别门店/供应商身份)
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID(小程序用户唯一标识)');
$table->string('unionid', 64)->default('')->comment('微信UnionID');
$table->string('phone', 20)->default('')->comment('手机号(微信授权获取,用于匹配门店/供应商)');
$table->string('avatar', 255)->default('')->comment('头像');
$table->integer('type')->default(0)->comment('用户类型(0待绑定 1门店 2供应商)');
$table->integer('store_id')->default(0)->comment('关联门店IDtype=1时有效)');
$table->integer('supplier_id')->default(0)->comment('关联供应商IDtype=2时有效)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
$table->rememberToken();
$table->timestamps();
$table->comment('APP用户表');
$table->index(['type', 'store_id'], 'user_type_store_index');
$table->comment('APP用户表(含小程序用户)');
});
}
}
@@ -0,0 +1,94 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 基础档案:客户等级、门店、供应商、消息通知(小程序用户已并入 user 表)
*/
public function up(): void
{
// 客户等级表(价格体系按等级定价)
if (! Schema::hasTable('customer_level')) {
Schema::create('customer_level', function (Blueprint $table) {
$table->increments('id')->comment('等级ID');
$table->string('name', 50)->comment('等级名称(如:一级客户、二级客户)');
$table->integer('sort')->default(0)->comment('排序');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->comment('客户等级表');
});
}
// 门店表(小程序下单主体,即客户)
if (! Schema::hasTable('store')) {
Schema::create('store', function (Blueprint $table) {
$table->increments('id')->comment('门店ID');
$table->string('name', 100)->comment('门店名称');
$table->string('code', 50)->unique()->comment('门店编码');
$table->integer('level_id')->default(0)->comment('客户等级ID(决定商品价格)');
$table->string('contact', 50)->default('')->comment('联系人');
$table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),门店可自行修改,影响对账单应结算日期');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->softDeletes();
$table->index(['level_id', 'status'], 'store_level_status_index');
$table->comment('门店表');
});
}
// 供应商表
if (! Schema::hasTable('supplier')) {
Schema::create('supplier', function (Blueprint $table) {
$table->increments('id')->comment('供应商ID');
$table->string('name', 100)->comment('供应商名称');
$table->string('contact', 50)->default('')->comment('联系人');
$table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('地址');
$table->string('main_products', 255)->default('')->comment('主营品类');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->softDeletes();
$table->comment('供应商表');
});
}
// 消息通知表(订单状态变更、价格调整等通知)
// 小程序用户已并入 user 表,不再单独建 mini_user 表
if (! Schema::hasTable('notice')) {
Schema::create('notice', function (Blueprint $table) {
$table->increments('id')->comment('通知ID');
$table->integer('user_id')->default(0)->comment('接收用户IDuser表,0为全员广播)');
$table->string('type', 20)->default('system')->comment('通知类型(order订单 price价格 system系统)');
$table->string('title', 100)->comment('标题');
$table->string('content', 500)->default('')->comment('内容');
$table->json('data')->nullable()->comment('业务关联数据(如订单ID');
$table->integer('is_read')->default(0)->comment('是否已读(1已读 0未读)');
$table->timestamp('read_at')->nullable()->comment('阅读时间');
$table->timestamps();
$table->index(['user_id', 'is_read'], 'notice_user_read_index');
$table->comment('消息通知表');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('customer_level');
Schema::dropIfExists('store');
Schema::dropIfExists('supplier');
Schema::dropIfExists('notice');
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 商品中心(A1-A3):商品分类、商品档案、客户等级价格体系
*/
public function up(): void
{
// 商品分类表(A3 多级分类:蔬菜/水果/其他,子分类排序)
if (! Schema::hasTable('product_category')) {
Schema::create('product_category', function (Blueprint $table) {
$table->increments('id')->comment('分类ID');
$table->integer('parent_id')->default(0)->comment('父级分类ID0为顶级)');
$table->string('name', 50)->comment('分类名称');
$table->integer('sort')->default(0)->comment('排序(采购单导出按此排序)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->timestamps();
$table->index(['parent_id', 'sort'], 'product_category_parent_sort_index');
$table->comment('商品分类表');
});
}
// 商品表(A1 商品档案:品名、规格/包规、供应商、等级)
if (! Schema::hasTable('product')) {
Schema::create('product', function (Blueprint $table) {
$table->increments('id')->comment('商品ID');
$table->integer('category_id')->default(0)->comment('商品分类ID');
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
$table->string('name', 100)->comment('品名');
$table->string('spec', 100)->default('')->comment('规格/包规');
$table->string('grade', 50)->default('')->comment('商品等级');
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
$table->string('image', 255)->default('')->comment('商品图片');
$table->integer('sort')->default(0)->comment('排序');
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->softDeletes();
$table->index(['category_id', 'status'], 'product_category_status_index');
$table->index(['supplier_id'], 'product_supplier_index');
$table->comment('商品表');
});
}
// 商品等级价格表(A2 价格策略:同一商品对不同客户等级显示不同单价)
if (! Schema::hasTable('product_price')) {
Schema::create('product_price', function (Blueprint $table) {
$table->increments('id')->comment('价格ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('level_id')->comment('客户等级ID');
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价');
$table->timestamps();
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
$table->comment('商品等级价格表');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_category');
Schema::dropIfExists('product');
Schema::dropIfExists('product_price');
}
};
@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 门店订货:小程序下单(品名/单价/订货量/重量/单品金额/总金额)
*/
public function up(): void
{
// 门店订货单表
if (! Schema::hasTable('store_order')) {
Schema::create('store_order', function (Blueprint $table) {
$table->increments('id')->comment('订单ID');
$table->string('order_no', 32)->unique()->comment('订单编号');
$table->integer('store_id')->comment('门店ID');
$table->date('order_date')->comment('订货日期');
$table->decimal('total_quantity', 10, 2)->default(0)->comment('订货总量');
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
$table->integer('status')->default(0)->comment('订单状态(0待汇总 1已汇总 2配送中 3已完成 4已取消)');
$table->string('remark', 255)->default('')->comment('订单备注');
$table->timestamps();
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
$table->index(['status'], 'store_order_status_index');
$table->comment('门店订货单表');
});
}
// 门店订货明细表(下单时快照等级单价,价格变更不影响历史订单)
if (! Schema::hasTable('store_order_item')) {
Schema::create('store_order_item', function (Blueprint $table) {
$table->increments('id')->comment('明细ID');
$table->integer('order_id')->comment('订单ID');
$table->integer('store_id')->comment('门店ID(冗余,便于按门店筛选)');
$table->integer('product_id')->comment('商品ID');
$table->string('product_name', 100)->comment('品名(快照)');
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
$table->decimal('price', 10, 2)->default(0)->comment('单价(下单时客户等级价快照)');
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
$table->string('remark', 255)->default('')->comment('门店下单备注');
$table->timestamps();
$table->index(['order_id'], 'store_order_item_order_index');
$table->index(['store_id', 'product_id'], 'store_order_item_store_product_index');
$table->comment('门店订货明细表');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('store_order');
Schema::dropIfExists('store_order_item');
}
};
@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 采购管理(C1-C6):采购单、采购明细(发送供应商)、采购金额分摊(D3)
*/
public function up(): void
{
// 采购单表(C1 基于所有门店订单汇总生成)
if (! Schema::hasTable('purchase_order')) {
Schema::create('purchase_order', function (Blueprint $table) {
$table->increments('id')->comment('采购单ID');
$table->string('purchase_no', 32)->unique()->comment('采购单编号');
$table->date('purchase_date')->comment('采购日期');
$table->integer('status')->default(0)->comment('采购单状态(0草稿 1已确认 2部分发送 3全部发送 4已完成)');
$table->decimal('total_quantity', 10, 2)->default(0)->comment('采购总量');
$table->decimal('total_weight', 10, 3)->default(0)->comment('采购总重量');
$table->decimal('estimate_amount', 10, 2)->default(0)->comment('预估金额(按订货汇总)');
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额');
$table->integer('operator_id')->default(0)->comment('采购员(系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->index(['purchase_date', 'status'], 'purchase_order_date_status_index');
$table->comment('采购单表');
});
}
// 采购单明细表(C2/C3 按分类排序导出,C4 可修改,C5/C6 发送供应商及状态标记)
if (! Schema::hasTable('purchase_order_item')) {
Schema::create('purchase_order_item', function (Blueprint $table) {
$table->increments('id')->comment('明细ID');
$table->integer('purchase_id')->comment('采购单ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('supplier_id')->default(0)->comment('供应商ID');
$table->string('product_name', 100)->comment('品名(快照)');
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
$table->decimal('price', 10, 2)->default(0)->comment('采购单价');
$table->decimal('quantity', 10, 2)->default(0)->comment('采购量');
$table->decimal('weight', 10, 3)->default(0)->comment('实际称重');
$table->decimal('amount', 10, 2)->default(0)->comment('采购金额');
$table->integer('sort')->default(0)->comment('排序(导出用)');
$table->integer('is_sent')->default(0)->comment('是否已发送供应商(1已发送 0未发送)');
$table->timestamp('sent_at')->nullable()->comment('发送时间');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->index(['purchase_id'], 'purchase_order_item_purchase_index');
$table->index(['supplier_id', 'is_sent'], 'purchase_order_item_supplier_sent_index');
$table->comment('采购单明细表');
});
}
// 采购分摊表(D3 采购金额自动分配到各门店/各单品)
if (! Schema::hasTable('purchase_allocation')) {
Schema::create('purchase_allocation', function (Blueprint $table) {
$table->increments('id')->comment('分摊ID');
$table->integer('purchase_item_id')->comment('采购单明细ID');
$table->integer('order_item_id')->comment('门店订货明细ID');
$table->integer('store_id')->comment('分摊到的门店ID');
$table->integer('product_id')->comment('商品ID');
$table->decimal('quantity', 10, 2)->default(0)->comment('分摊数量');
$table->decimal('weight', 10, 3)->default(0)->comment('分摊重量');
$table->decimal('amount', 10, 2)->default(0)->comment('分摊金额');
$table->timestamps();
$table->index(['purchase_item_id'], 'purchase_allocation_item_index');
$table->index(['order_item_id'], 'purchase_allocation_order_item_index');
$table->index(['store_id'], 'purchase_allocation_store_index');
$table->comment('采购分摊表');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_order');
Schema::dropIfExists('purchase_order_item');
Schema::dropIfExists('purchase_allocation');
}
};
@@ -0,0 +1,139 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 对账管理(D1-D10):财务对账、门店对账单、结算表
*/
public function up(): void
{
// 财务对账单表(D1 按品类对账、D2 按供应商筛选)
if (! Schema::hasTable('reconciliation')) {
Schema::create('reconciliation', function (Blueprint $table) {
$table->increments('id')->comment('对账ID');
$table->string('recon_no', 32)->unique()->comment('对账单编号');
$table->string('title', 100)->comment('对账单标题');
$table->date('period_start')->comment('对账周期开始');
$table->date('period_end')->comment('对账周期结束');
$table->integer('category_id')->default(0)->comment('按品类筛选(0为全部,D1');
$table->integer('supplier_id')->default(0)->comment('按供应商筛选(0为全部,D2');
$table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额合计(D5');
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额合计(D5');
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计(D5');
$table->integer('status')->default(0)->comment('状态(0对账中 1已完成 2已生成结算表)');
$table->integer('operator_id')->default(0)->comment('对账员(系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->index(['period_start', 'period_end'], 'reconciliation_period_index');
$table->comment('财务对账单表');
});
}
// 财务对账明细表(D4 数据修改、D5 差额对比、D6 单品级门店备注、D8 对账状态标记)
if (! Schema::hasTable('reconciliation_item')) {
Schema::create('reconciliation_item', function (Blueprint $table) {
$table->increments('id')->comment('明细ID');
$table->integer('recon_id')->comment('财务对账单ID');
$table->integer('store_id')->comment('门店ID');
$table->integer('purchase_item_id')->default(0)->comment('采购单明细ID');
$table->integer('order_item_id')->default(0)->comment('门店订货明细ID');
$table->integer('product_id')->comment('商品ID');
$table->string('product_name', 100)->comment('品名(快照)');
$table->decimal('quantity', 10, 2)->default(0)->comment('数量(D4可修改)');
$table->decimal('weight', 10, 3)->default(0)->comment('称重数据(D4可修改)');
$table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额(门店订货金额)');
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额(分摊)');
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额');
$table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账,D8)');
$table->string('store_remark', 255)->default('')->comment('单品级门店备注(D6');
$table->integer('sort')->default(0)->comment('排序');
$table->timestamps();
$table->index(['recon_id'], 'reconciliation_item_recon_index');
$table->index(['store_id', 'is_reconciled'], 'reconciliation_item_store_index');
$table->comment('财务对账明细表');
});
}
// 门店对账单表(门店在小程序端自助生成,回款周期快照决定应结算日期)
if (! Schema::hasTable('statement')) {
Schema::create('statement', function (Blueprint $table) {
$table->increments('id')->comment('对账单ID');
$table->string('statement_no', 32)->unique()->comment('对账单编号');
$table->integer('store_id')->comment('门店ID');
$table->date('period_start')->comment('对账周期开始');
$table->date('period_end')->comment('对账周期结束');
$table->decimal('total_amount', 10, 2)->default(0)->comment('对账总金额');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),生成时从门店快照');
$table->date('settlement_date')->nullable()->comment('应结算日期(按回款周期计算)');
$table->integer('status')->default(0)->comment('状态(0未对账 1已对账 2已结算)');
$table->timestamp('reconciled_at')->nullable()->comment('对账完成时间');
$table->timestamp('settled_at')->nullable()->comment('结算时间');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->index(['store_id', 'period_start'], 'statement_store_period_index');
$table->comment('门店对账单表');
});
}
// 门店对账单明细表(每个单品/订单的对账状态标识)
if (! Schema::hasTable('statement_item')) {
Schema::create('statement_item', function (Blueprint $table) {
$table->increments('id')->comment('明细ID');
$table->integer('statement_id')->comment('对账单ID');
$table->integer('order_id')->comment('订单ID');
$table->integer('order_item_id')->comment('订货明细ID');
$table->integer('product_id')->comment('商品ID');
$table->string('product_name', 100)->comment('品名(快照)');
$table->decimal('price', 10, 2)->default(0)->comment('单价');
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
$table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账)');
$table->string('store_remark', 255)->default('')->comment('门店备注');
$table->timestamps();
$table->index(['statement_id'], 'statement_item_statement_index');
$table->comment('门店对账单明细表');
});
}
// 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档)
if (! Schema::hasTable('settlement')) {
Schema::create('settlement', function (Blueprint $table) {
$table->increments('id')->comment('结算ID');
$table->string('settlement_no', 32)->unique()->comment('结算单编号');
$table->integer('recon_id')->default(0)->comment('关联财务对账单ID');
$table->integer('store_id')->default(0)->comment('门店ID0为汇总结算)');
$table->date('period_start')->comment('结算周期开始');
$table->date('period_end')->comment('结算周期结束');
$table->decimal('total_amount', 10, 2)->default(0)->comment('结算总金额(公布)');
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购总金额');
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计');
$table->integer('status')->default(0)->comment('状态(0待结算 1已结算)');
$table->string('file_path', 255)->default('')->comment('导出文件路径(Excel/PDFD10');
$table->integer('operator_id')->default(0)->comment('操作人(系统用户ID');
$table->timestamp('settled_at')->nullable()->comment('结算时间');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->index(['store_id', 'status'], 'settlement_store_status_index');
$table->comment('结算表');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reconciliation');
Schema::dropIfExists('reconciliation_item');
Schema::dropIfExists('statement');
Schema::dropIfExists('statement_item');
Schema::dropIfExists('settlement');
}
};
Binary file not shown.
+514
View File
@@ -0,0 +1,514 @@
****# 订货采购系统 · 开发计划
> 版本: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 个新模型,含关系/常量/工厂) | ⬜ 未开始 |
| 三 | PC 后台 API`app/Http/Controllers` 下 5 个业务域 + FormRequest + Service | ⬜ 未开始 |
| 四 | 小程序 API`app/Http/Controllers/Mini`,微信登录 + 门店端 + 供应商端) | ⬜ 未开始 |
| 五 | PC 前端页面 + 菜单权限 Seeder(硬编码中文,无 i18n) | ⬜ 未开始 |
| 六 | PHPUnit 功能测试 | ⬜ 未开始 |
---
## 二、架构设计
### 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`(需业务方提供)。
### 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/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,增删改查请求自动封装,按钮自动套 `<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 天 |
每完成一个后端域即联调对应前端页面。
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
项目需求规划书
项目名称:订货采购系统
前端形态:微信小程序(门店端/客户端)
文档版本:V1.0
编制日期:2026年7月
一、项目概述
1.1 项目背景
本项目旨在为生鲜配送企业打造一套覆盖“采购→订货→配送→对账”全链路的数字化管理系统。前端采用微信小程序形态,服务于门店端下单与客户端对账两大核心场景,后端配合PC管理后台完成商品、采购、对账等复杂管理操作。
1.2 项目目标
a.实现门店通过小程序在线下单,系统自动匹配客户等级价格
b.打通门店订单与采购单的自动汇总生成链路
c.建立供应商在线协同机制(采购单发送、对账确认)
d.实现财务对账数字化,支持按品类/供应商筛选、差额对比、结算表生成
e.支持门店在客户端自助生成对账单并导出
1.3 用户角色与使用场景
角色 使用端 核心场景
门店/客户 小程序客户端 在线下单、查看价格、查看对账单
采购员 PC后台 生成采购单、修改采购数据、发送供应商
财务/对账员 PC后台 对账管理、差额对比、结算表生成
供应商 微信小程序 接收采购单、确认订单
系统管理员 PC后台 商品管理、价格策略、权限配置
二、小程序端功能规划
2.1 门店端小程序(核心订货场景)
门店通过小程序浏览商品、下单,展示字段:品名、单价、订货量、重量、单品金额、总金额。
功能点 说明
商品列表浏览 按分类展示可订购商品,支持搜索/筛选
加入订货车 选择商品、填写订货量/重量,加入购物车式订货单
订货单确认 展示完整订货明细(品名/单价/订货量/重量/单品金额),确认后提交
历史订单查看 查看历史订货记录及状态
根据登录门店的客户等级,自动匹配并显示对应单价。价格数据由后台商品中心的价格体系驱动。
功能点 说明
客户等级识别 登录时获取门店等级,全局生效
价格自动匹配 商品列表和订货车中按等级展示单价
价格变更提示 后台调价后,门店端下次登录同步更新
门店可查看自身各时期的订货金额汇总。
功能点 说明
门店订货汇总 按日/周/月查看本店订货总金额
明细下钻 点击汇总金额可查看对应订单明细
门店可在客户端生成自己的对账单,支持导出。
功能点 说明
对账单查看 按时间段生成对账单,展示订货明细、金额
对账单导出 支持导出为Excel或PDF格式
对账状态标识 显示每个单品/订单的对账状态(已对账/未对账)
门店可自行修改回款周期(0天/1天/2天……无限制),影响对账单中的结算日期计算。
功能点 说明
回款周期配置 门店在个人设置中选择回款周期选项
结算日期自动计算 对账单根据回款周期生成应结算日期
2.2 小程序端基础功能
功能点 说明
微信授权登录 通过微信手机号授权登录,自动识别门店身份
权限控制 不同门店仅可见自身数据
消息通知 订单状态变更、价格调整等通知推送
三、PC后台管理端功能规划(概要)
3.1 商品中心(A1-A3
A1 商品档案管理:增删改查商品,字段包含品名、规格/包规、供应商、等级、价格体系(多等级客户价)
A2 价格策略:同一商品对不同客户等级显示不同单价,支持批量调价
A3 分类管理:支持多级分类(蔬菜/水果/其他),子分类排序
3.2 采购管理(C1-C6
C1 采购单生成:基于所有门店订单汇总生成,支持按样板格式导出
C2 全品类导出:按子分类排序,所有列可筛选/排序/恢复初始排序
C3 蔬果单独导出:按品类拆分开出
C4 采购单修改:采购环节可直接修改商品信息及门店订单数据
C5 采购单发送供应商:通过微信快捷发送(文件分享/小程序转发/链接)
C6 发送状态标记:标记每个单品是否已发送供应商
3.3 对账管理(D1-D10
D1 按品类对账:蔬菜/水果分开对账
D2 按供应商筛选对账:只查看特定供应商的单据
D3 采购金额自动分配:系统计算分摊到各门店/各单品
D4 对账数据修改:支持修改订货量、称重数据、数量、金额及商品信息
D5 差额对比:显示实际采购金额、公布金额、差额
D6 单品级门店备注:每个单品可针对每个门店单独添加备注
D7 特殊业务处理:周转柜、周转托盘、调货、售后、物流(需业务侧确定功能具体需求)
D8 对账状态标记:标记每个单品已对账/未对账
D9 结算表生成:对账结束后生成结算表、回框统计表
D10 下载存档:支持文件下载(Excel/PDF)
3.4 公共/基础(F1-F3
F1 登录/权限/用户管理:后台管理系统基础
F2 前后端接口联调:门店端、客户端接口对接
F3 页面:前端UI页面整体搭建
四、其它功能
1.采购流转:客户在小程序(客户平台)注册后,在小程序下单采购订单。
2.订单流转:供应商在微信小程序(供应商平台)注册后,由采购员生成具体的采购单,发送给供应商。
3.周转柜:待补充
4.周转托盘:待补充
5.调货:待补充
6.售后:待补充
7.物流:待补充
8.导出格式:待补充
五、开发阶段规划
阶段一:需求确认与原型设计(5天)
1、确认全部功能需求细节(特别是D7特殊业务处理需业务方明确规则)
2、完成小程序端原型图设计(门店端订货流程、客户端对账流程)
阶段二:小程序端开发(10天)
1、门店端小程序:商品浏览、订货车、下单、历史订单
2、客户端小程序:对账单查看、导出、回款周期设置
3、登录与权限体系
4、前后端接口联调
阶段三:PC后台开发(10天)
1、商品中心模块
2、采购管理模块(含发送供应商)
3、对账管理模块(含结算表生成、下载存档)
4、权限管理系统
5、导出功能,支持Excel格式导出
阶段四:测试与上线(5天)
1、功能测试、兼容性测试、性能测试
2、小程序提交微信审核
3、部署上线
六、风险与注意事项
1、特殊业务处理:周转柜、周转托盘、调货、售后、物流是否涉及重大变动,需业务方明确规则,否则影响系统设计和与开发进度
2、价格实时性:后台调价后需确保门店端及时同步
3、数据安全:对账单涉及金额数据,需做好权限隔离,确保门店仅可见自身数据