first version

This commit is contained in:
liu
2026-07-23 20:41:25 +08:00
parent 00a0938a1b
commit 10cff754a4
211 changed files with 11577 additions and 842 deletions
+1
View File
@@ -0,0 +1 @@
{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.002,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.008,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.013,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.008,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.008,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.009,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.009,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.013,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.01,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.03,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.024,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.071,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.02,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.023,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.016,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.027,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.032,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.028,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.027,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.03,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.015,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.008,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.013,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.012,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.006,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.005,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.059,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"Tests\\Feature\\ExportTest::test_export_pdf":1.324,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.004}}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Exports;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\SupplierModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类)
*/
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
/** @var array<int, string> 商品ID => 顶级分类名 */
private array $categoryNames = [];
/** @var array<int, string> 供应商ID => 名称 */
private array $supplierNames = [];
private ?Collection $items = null;
/**
* @param PurchaseOrderModel $purchase 采购单
* @param string $type all 全品类 / category 仅蔬果分类
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly string $type = 'all',
) {
}
/**
* 导出行(按 sort 排序;蔬果分类时按顶级分类名过滤)
*/
public function collection(): Collection
{
if ($this->items !== null) {
return $this->items;
}
$items = $this->purchase->items()->orderBy('sort')->get();
$this->loadLookups($items);
if ($this->type === 'category') {
$items = $items->filter(function ($item): bool {
$rootName = $this->categoryNames[(int) $item->product_id] ?? '';
return str_contains($rootName, '蔬菜') || str_contains($rootName, '水果');
})->values();
}
return $this->items = $items;
}
public function headings(): array
{
return ['序号', '分类', '品名', '规格/包规', '单价', '数量', '实际称重', '金额', '供应商', '备注'];
}
public function map($item): array
{
return [
$item->sort,
$this->categoryNames[(int) $item->product_id] ?? '',
$item->product_name,
$item->product_spec,
(float) $item->price,
(float) $item->quantity,
(float) $item->weight,
(float) $item->amount,
$this->supplierNames[(int) $item->supplier_id] ?? '',
$item->remark,
];
}
/**
* 表头加粗 + 冻结首行
*/
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* PDF 模板视图数据
*
* @return array{purchase: PurchaseOrderModel, items: Collection, categoryNames: array<int, string>, supplierNames: array<int, string>}
*/
public function viewData(): array
{
return [
'purchase' => $this->purchase,
'items' => $this->collection(),
'categoryNames' => $this->categoryNames,
'supplierNames' => $this->supplierNames,
];
}
/**
* 预加载供应商名与商品顶级分类名(商品/供应商均含软删除,保证历史单据可导出)
*/
private function loadLookups(Collection $items): void
{
$this->supplierNames = SupplierModel::withTrashed()
->whereIn('id', $items->pluck('supplier_id')->unique())
->pluck('name', 'id')
->toArray();
$productCategoryIds = ProductModel::withTrashed()
->whereIn('id', $items->pluck('product_id')->unique())
->pluck('category_id', 'id');
$categories = ProductCategoryModel::all()->keyBy('id');
$this->categoryNames = [];
foreach ($productCategoryIds as $productId => $categoryId) {
$rootName = '';
$cursor = (int) $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20) {
$category = $categories->get($cursor);
if ($category === null) {
break;
}
$rootName = $category->name;
$cursor = (int) $category->parent_id;
}
$this->categoryNames[(int) $productId] = $rootName;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Exports;
use App\Models\ReconciliationItemModel;
use App\Models\SettlementModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* D10 结算表导出(结算头 + 来源对账单中该门店的明细)
*/
class SettlementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
public function __construct(private readonly SettlementModel $settlement)
{
}
public function collection(): Collection
{
return ReconciliationItemModel::query()
->where('recon_id', $this->settlement->recon_id)
->where('store_id', $this->settlement->store_id)
->orderBy('sort')
->get();
}
public function headings(): array
{
return ['品名', '数量', '称重', '公布金额', '实际金额', '差额', '对账状态', '门店备注'];
}
public function map($item): array
{
return [
$item->product_name,
(float) $item->quantity,
(float) $item->weight,
(float) $item->publish_amount,
(float) $item->actual_amount,
(float) $item->diff_amount,
$item->is_reconciled ? '已对账' : '未对账',
$item->store_remark,
];
}
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* PDF 模板视图数据
*
* @return array{settlement: SettlementModel, storeName: string, reconNo: string, items: Collection}
*/
public function viewData(): array
{
return [
'settlement' => $this->settlement,
'storeName' => $this->settlement->store?->name ?? '',
'reconNo' => $this->settlement->recon?->recon_no ?? '',
'items' => $this->collection(),
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Exports;
use App\Models\StatementModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店对账单导出
*/
class StatementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
public function __construct(private readonly StatementModel $statement)
{
}
public function collection(): Collection
{
return $this->statement->items()->orderBy('id')->get();
}
public function headings(): array
{
return ['品名', '单价', '数量', '重量', '金额', '对账状态', '备注'];
}
public function map($item): array
{
return [
$item->product_name,
(float) $item->price,
(float) $item->quantity,
(float) $item->weight,
(float) $item->amount,
$item->is_reconciled ? '已对账' : '未对账',
$item->store_remark,
];
}
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* PDF 模板视图数据
*
* @return array{statement: StatementModel, storeName: string, items: Collection}
*/
public function viewData(): array
{
return [
'statement' => $this->statement,
'storeName' => $this->statement->store?->name ?? '',
'items' => $this->collection(),
];
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\CustomerLevelFormRequest;
use App\Models\CustomerLevelModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 客户等级管理(同一商品按客户等级定价)
*/
#[RequestAttribute('/customer/level', 'customer.level')]
class CustomerLevelController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'status' => '=',
];
/** 等级列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, CustomerLevelModel::query())
->orderBy('sort')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建等级 */
#[PostRoute(authorize: 'create')]
public function create(CustomerLevelFormRequest $request): JsonResponse
{
CustomerLevelModel::create($request->validated());
return $this->success();
}
/** 编辑等级 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
{
$model = CustomerLevelModel::find($id);
if (empty($model)) {
throw new RepositoryException('客户等级不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除等级(被门店引用时拒绝) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = CustomerLevelModel::find($id);
if (empty($model)) {
throw new RepositoryException('客户等级不存在');
}
if ($model->stores()->exists()) {
throw new RepositoryException('该等级下存在门店,无法删除');
}
if ($model->prices()->exists()) {
throw new RepositoryException('该等级下存在商品价格,无法删除');
}
$model->delete();
return $this->success();
}
/** 等级下拉选项(门店表单 / 价格矩阵用) */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = CustomerLevelModel::query()
->where('status', CustomerLevelModel::STATUS_NORMAL)
->orderBy('sort')
->get(['id', 'name'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\MiniUserBindRequest;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 小程序用户管理(用户由小程序登录自动生成,后台只做绑定与状态管理,无增删)
*/
#[RequestAttribute('/customer/miniUser', 'customer.miniUser')]
class MiniUserController extends BaseController
{
protected array $searchField = [
'type' => '=',
'store_id' => '=',
'supplier_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['nickname', 'phone'];
/** 小程序用户列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name', 'supplier:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 绑定门店/供应商(一个门店可绑多个账号,一个账号只绑一个主体)
*/
#[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])]
public function bind(int $id, MiniUserBindRequest $request): JsonResponse
{
$validated = $request->validated();
$user = UserModel::find($id);
if (empty($user)) {
throw new RepositoryException('用户不存在');
}
if ((int) $validated['type'] === UserModel::TYPE_STORE) {
$store = StoreModel::find((int) $validated['store_id']);
if (empty($store)) {
throw new RepositoryException('门店不存在');
}
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::find((int) $validated['supplier_id']);
if (empty($supplier)) {
throw new RepositoryException('供应商不存在');
}
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
}
$user->save();
return $this->success();
}
/** 启用/停用(停用后登录时检查 status 拒绝,token 鉴权拦截) */
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'status' => 'required|integer|in:0,1',
], [
'status.required' => '状态不能为空',
'status.in' => '状态值不正确',
]);
$user = UserModel::find($id);
if (empty($user)) {
throw new RepositoryException('用户不存在');
}
$user->status = (int) $data['status'];
$user->save();
return $this->success();
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\NoticeFormRequest;
use App\Models\NoticeModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 通知管理(小程序端消息;user_id=0 为全员广播)
*/
#[RequestAttribute('/customer/notice', 'customer.notice')]
class NoticeController extends BaseController
{
protected array $searchField = [
'type' => '=',
'is_read' => '=',
'user_id' => '=',
];
protected array $quickSearchField = ['title', 'content'];
/** 通知列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, NoticeModel::query())
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 发布通知(user_id=0 全员广播) */
#[PostRoute(authorize: 'create')]
public function create(NoticeFormRequest $request): JsonResponse
{
NoticeModel::create($request->validated() + ['is_read' => NoticeModel::UNREAD]);
return $this->success();
}
/** 删除通知 */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = NoticeModel::find($id);
if (empty($model)) {
throw new RepositoryException('通知不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\StoreFormRequest;
use App\Models\StoreModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 门店管理(小程序下单主体,即客户)
*/
#[RequestAttribute('/customer/store', 'customer.store')]
class StoreController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'code' => 'like',
'level_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['name', 'code', 'contact', 'phone'];
/** 门店列表(含等级名回显) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StoreModel::query()->with('level:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建门店 */
#[PostRoute(authorize: 'create')]
public function create(StoreFormRequest $request): JsonResponse
{
StoreModel::create($request->validated());
return $this->success();
}
/** 编辑门店 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, StoreFormRequest $request): JsonResponse
{
$model = StoreModel::find($id);
if (empty($model)) {
throw new RepositoryException('门店不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除门店(软删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = StoreModel::find($id);
if (empty($model)) {
throw new RepositoryException('门店不存在');
}
$model->delete();
return $this->success();
}
/** 门店下拉选项(小程序用户绑定、订单筛选用) */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = StoreModel::query()
->where('status', StoreModel::STATUS_NORMAL)
->orderBy('id')
->get(['id', 'name', 'code'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\SupplierFormRequest;
use App\Models\SupplierModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 供应商管理(采购单接收方)
*/
#[RequestAttribute('/customer/supplier', 'customer.supplier')]
class SupplierController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'status' => '=',
];
protected array $quickSearchField = ['name', 'contact', 'phone'];
/** 供应商列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, SupplierModel::query())
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建供应商 */
#[PostRoute(authorize: 'create')]
public function create(SupplierFormRequest $request): JsonResponse
{
SupplierModel::create($request->validated());
return $this->success();
}
/** 编辑供应商 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, SupplierFormRequest $request): JsonResponse
{
$model = SupplierModel::find($id);
if (empty($model)) {
throw new RepositoryException('供应商不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除供应商(软删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = SupplierModel::find($id);
if (empty($model)) {
throw new RepositoryException('供应商不存在');
}
$model->delete();
return $this->success();
}
/** 供应商下拉选项 */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = SupplierModel::query()
->where('status', SupplierModel::STATUS_NORMAL)
->orderBy('id')
->get(['id', 'name'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use App\Services\WechatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序认证(微信登录 / 手机号绑定 / 当前用户信息)
*
* authGuard: usersprovider 指向 UserModel,与后台 sys_users 天然隔离);
* authorize: true 仅要求登录(sanctum + authGuard:users),不做细粒度权限点;
* token abilities ['mini'] 作来源标记。
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class AuthController extends BaseMiniController
{
/** 小程序登录:wx.login 的 code → openid → 自动注册/登录 → 签发 token */
#[PostRoute('/auth/login', authorize: false)]
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'code' => 'required|string',
], [
'code.required' => '缺少登录凭证 code',
]);
$session = app(WechatService::class)->code2Session($data['code']);
$user = UserModel::firstOrNew(['openid' => $session['openid']]);
$isNew = ! $user->exists;
if ($user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号已被停用,请联系客服');
}
if ($isNew) {
$user->type = UserModel::TYPE_PENDING;
$user->status = UserModel::STATUS_NORMAL;
}
if (! empty($session['unionid'])) {
$user->unionid = $session['unionid'];
}
$user->last_login_at = now();
$user->save();
$token = $user->createToken('mini', ['mini'])->plainTextToken;
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
], $isNew ? '注册成功' : '登录成功');
}
/**
* 绑定手机号:phoneCode 换手机号 → 按手机号自动匹配门店/供应商
* (命中门店 → type=1+store_id;命中供应商 → type=2+supplier_id;都不命中 → 保持待绑定,后台人工处理)
*/
#[PostRoute('/auth/phone', authorize: true)]
public function phone(Request $request): JsonResponse
{
$data = $request->validate([
'phoneCode' => 'required|string',
], [
'phoneCode.required' => '缺少手机号授权凭证 phoneCode',
]);
$phone = app(WechatService::class)->getPhone($data['phoneCode']);
$user = $this->currentUser($request);
$user->phone = $phone;
if (! $user->isBound()) {
$store = StoreModel::where('phone', $phone)
->where('status', StoreModel::STATUS_NORMAL)
->first();
if ($store !== null) {
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::where('phone', $phone)
->where('status', SupplierModel::STATUS_NORMAL)
->first();
if ($supplier !== null) {
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
}
}
}
$user->save();
return $this->success([
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
]);
}
/** 当前用户信息(含门店客户等级 —— 全局价格体系依据 / 供应商信息) */
#[GetRoute('/auth/info', authorize: true)]
public function info(Request $request): JsonResponse
{
$user = UserModel::with(['store.level:id,name', 'supplier:id,name'])
->find($request->user()->id);
if ($user === null) {
throw new RepositoryException('账号不存在');
}
return $this->success(['user' => $this->formatUser($user)]);
}
/**
* 小程序端用户信息输出结构
*
* @return array<string, mixed>
*/
private function formatUser(UserModel $user): array
{
return [
'id' => $user->id,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'phone' => $user->phone,
'type' => $user->type,
'store' => $user->store,
'supplier' => $user->supplier,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Http\Request;
use Modules\Common\Http\Controllers\BaseController;
/**
* 小程序端控制器基类
*
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
* 提供当前用户获取与门店/供应商绑定前置校验。
*/
abstract class BaseMiniController extends BaseController
{
/**
* 当前小程序用户(auth:sanctum 注入的 tokenable
*/
protected function currentUser(Request $request): UserModel
{
$user = UserModel::find($request->user()->id);
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号不存在或已被停用');
}
return $user;
}
/**
* 门店端前置校验:type=门店 且 store_id>0 且门店正常
*/
protected function ensureStoreBound(UserModel $user): StoreModel
{
if ($user->type !== UserModel::TYPE_STORE || $user->store_id <= 0) {
throw new RepositoryException('尚未绑定门店,请联系客服处理');
}
$store = StoreModel::find($user->store_id);
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
throw new RepositoryException('门店不存在或已停用,请联系客服处理');
}
return $store;
}
/**
* 供应商端前置校验:type=供应商 且 supplier_id>0 且供应商正常
*/
protected function ensureSupplierBound(UserModel $user): SupplierModel
{
if ($user->type !== UserModel::TYPE_SUPPLIER || $user->supplier_id <= 0) {
throw new RepositoryException('尚未绑定供应商,请联系客服处理');
}
$supplier = SupplierModel::find($user->supplier_id);
if ($supplier === null || $supplier->status !== SupplierModel::STATUS_NORMAL) {
throw new RepositoryException('供应商不存在或已停用,请联系客服处理');
}
return $supplier;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\NoticeModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序通知(本人通知 + 全员广播)
*
* 广播已读处理:user_id=0 的广播是全局共享记录,直接改 is_read 会影响其他用户,
* 因此标记已读时复制一条本人专属的已读记录(data.broadcast_from 记来源),
* 列表查询时排除已有已读副本的广播。
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class NoticeController extends BaseMiniController
{
/** 本人通知 + 全员广播(user_id in [0, 当前id]),分页 + unread_count */
#[GetRoute('/notice', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
// 本人已读过的广播来源ID(已读副本记录)
$readBroadcastIds = NoticeModel::query()
->where('user_id', $user->id)
->whereNotNull('data->broadcast_from')
->pluck('data->broadcast_from');
$query = NoticeModel::query()->where(function ($q) use ($user, $readBroadcastIds) {
$q->where('user_id', $user->id)
->orWhere(function ($broadcastQuery) use ($readBroadcastIds) {
$broadcastQuery->where('user_id', NoticeModel::BROADCAST_USER_ID);
if ($readBroadcastIds->isNotEmpty()) {
$broadcastQuery->whereNotIn('id', $readBroadcastIds->all());
}
});
});
$unreadCount = (clone $query)->where('is_read', NoticeModel::UNREAD)->count();
$data = $query->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
$data['unread_count'] = $unreadCount;
return $this->success($data);
}
/** 标记已读(广播 → 复制本人已读副本;个人通知 → 直接更新) */
#[PutRoute('/notice/{id}/read', authorize: true, where: ['id' => '[0-9]+'])]
public function read(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$notice = NoticeModel::whereIn('user_id', [NoticeModel::BROADCAST_USER_ID, $user->id])->find($id);
if ($notice === null) {
throw new RepositoryException('通知不存在');
}
if ($notice->user_id === NoticeModel::BROADCAST_USER_ID) {
$exists = NoticeModel::where('user_id', $user->id)
->where('data->broadcast_from', $notice->id)
->exists();
if (! $exists) {
NoticeModel::create([
'user_id' => $user->id,
'type' => $notice->type,
'title' => $notice->title,
'content' => $notice->content,
'data' => ['broadcast_from' => $notice->id] + (array) $notice->data,
'is_read' => NoticeModel::READ,
'read_at' => now(),
]);
}
} elseif ($notice->is_read === NoticeModel::UNREAD) {
$notice->is_read = NoticeModel::READ;
$notice->read_at = now();
$notice->save();
}
return $this->success();
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Mini\MiniOrderRequest;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillNumberService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店订单(下单 / 历史 / 详情 / 取消 / 周期汇总)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OrderController extends BaseMiniController
{
/**
* 下单:逐行取当前门店等级价快照,服务端重算 amount 与 total(不接受前端金额)
*/
#[PostRoute('/order', authorize: true)]
public function store(MiniOrderRequest $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
}
$items = $request->validated('items');
$remark = (string) ($request->validated('remark') ?? '');
$order = DB::transaction(function () use ($store, $items, $remark) {
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $productIds)
->get()
->keyBy('id');
$prices = ProductPriceModel::query()
->where('level_id', $store->level_id)
->whereIn('product_id', $productIds)
->pluck('price', 'product_id');
$totalQuantity = '0';
$totalAmount = '0';
$now = now();
$rows = [];
foreach ($items as $row) {
$productId = (int) $row['product_id'];
$product = $products->get($productId);
if ($product === null) {
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
}
if (! isset($prices[$productId])) {
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
}
$price = (string) $prices[$productId];
$quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2);
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2);
$rows[] = [
'store_id' => $store->id,
'product_id' => $productId,
'product_name' => $product->name,
'product_spec' => $product->spec,
'price' => $price,
'quantity' => $quantity,
'weight' => 0,
'amount' => $amount,
'remark' => '',
'created_at' => $now,
'updated_at' => $now,
];
}
$order = StoreOrderModel::create([
'order_no' => app(BillNumberService::class)->make('SO'),
'store_id' => $store->id,
'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark,
]);
foreach ($rows as &$itemRow) {
$itemRow['order_id'] = $order->id;
}
StoreOrderItemModel::insert($rows);
return $order;
});
return $this->success([
'id' => $order->id,
'order_no' => $order->order_no,
'total_amount' => $order->total_amount,
], '下单成功');
}
/** 历史订单:当前门店强制过滤,?status=&page=&pageSize= */
#[GetRoute('/order', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$query = StoreOrderModel::query()->where('store_id', $store->id);
if ($request->filled('status')) {
$query->where('status', (int) $request->input('status'));
}
$data = $query->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/**
* 按周期聚合金额/数量:?period=day|week|month(分组列表,period_label 可作下钻查询参数)
*/
#[GetRoute('/order/summary', authorize: true)]
public function summary(Request $request): JsonResponse
{
$period = (string) $request->query('period', 'month');
if (! in_array($period, ['day', 'week', 'month'], true)) {
throw new RepositoryException('period 参数只能是 day/week/month');
}
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite
$driver = DB::connection()->getDriverName();
if ($driver === 'sqlite') {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%Y-W%W',
default => '%Y-%m',
};
$labelExpr = "strftime('{$format}', order_date)";
} else {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%x-W%v',
default => '%Y-%m',
};
$labelExpr = "DATE_FORMAT(order_date, '{$format}')";
}
$rows = StoreOrderModel::query()
->where('store_id', $store->id)
->where('status', '<>', StoreOrderModel::STATUS_CANCELLED)
->selectRaw("{$labelExpr} as period_label")
->selectRaw('SUM(total_amount) as total_amount, SUM(total_quantity) as total_quantity, COUNT(*) as order_count')
->groupBy('period_label')
->orderByDesc('period_label')
->limit(50)
->get()
->toArray();
return $this->success(['period' => $period, 'groups' => $rows]);
}
/** 订单详情(校验归属:仅能查看本店订单) */
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$order = StoreOrderModel::with('items')
->where('store_id', $store->id)
->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
return $this->success($order->toArray());
}
/** 取消订单(仅待汇总可取消) */
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
public function cancel(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
if ($order->status !== StoreOrderModel::STATUS_PENDING) {
throw new RepositoryException('仅待汇总的订单可以取消');
}
$order->status = StoreOrderModel::STATUS_CANCELLED;
$order->save();
return $this->success([], '订单已取消');
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序商品(分类树 + 列表,价格 = 当前门店客户等级价)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class ProductController extends BaseMiniController
{
/** 分类树(仅含上架商品的分类及其祖先,保证树结构完整) */
#[GetRoute('/product/categories', authorize: true)]
public function categories(): JsonResponse
{
$activeCategoryIds = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->distinct()
->pluck('category_id')
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id) => $id > 0);
$categories = ProductCategoryModel::query()
->where('status', ProductCategoryModel::STATUS_NORMAL)
->get()
->keyBy('id');
// 保留有上架商品的分类 + 其全部祖先
$keep = [];
foreach ($activeCategoryIds as $categoryId) {
$cursor = $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20 && $categories->has($cursor)) {
$keep[$cursor] = true;
$cursor = (int) $categories[$cursor]->parent_id;
}
}
$filtered = array_values(array_filter(
$categories->toArray(),
static fn (array $item) => isset($keep[$item['id']])
));
return $this->success(ProductCategoryModel::buildTree($filtered));
}
/**
* 商品列表:价格取当前门店客户等级价(未绑等级的门店报错);
* ?category_id=&keyword=&page=&pageSize=
*/
#[GetRoute('/product/list', authorize: true)]
public function products(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法展示价格,请联系客服');
}
$query = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->with('category:id,name')
->with(['prices' => static fn ($q) => $q->where('level_id', $store->level_id)]);
$categoryId = (int) $request->input('category_id', 0);
if ($categoryId > 0) {
$query->where('category_id', $categoryId);
}
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where(static function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%')
->orWhere('spec', 'like', '%' . $keyword . '%');
});
}
$pageSize = (int) $request->input('pageSize', 10);
$data = $query->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
// 扁平化价格:prices[0].price → price(未设等级价为 null
foreach ($data['data'] as &$row) {
$row['price'] = $row['prices'][0]['price'] ?? null;
unset($row['prices']);
}
return $this->success($data);
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StatementModel;
use App\Services\ExportService;
use App\Services\StatementGenerateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Symfony\Component\HttpFoundation\Response;
/**
* 小程序门店对账单(自助生成 / 查看 / 导出)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class StatementController extends BaseMiniController
{
/** 对账单列表(当前门店) */
#[GetRoute('/statement', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$data = StatementModel::query()
->where('store_id', $store->id)
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 生成对账单:快照当前回款周期,settlement_date = period_end + cycle 天 */
#[PostRoute('/statement/generate', authorize: true)]
public function generate(Request $request): JsonResponse
{
$data = $request->validate([
'period_start' => 'required|date_format:Y-m-d',
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
], [
'period_start.required' => '请选择对账周期开始日期',
'period_start.date_format' => '开始日期格式为 Y-m-d',
'period_end.required' => '请选择对账周期结束日期',
'period_end.date_format' => '结束日期格式为 Y-m-d',
'period_end.after_or_equal' => '结束日期不能早于开始日期',
]);
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = app(StatementGenerateService::class)->generate(
$store,
$data['period_start'],
$data['period_end'],
);
return $this->success([
'id' => $statement->id,
'statement_no' => $statement->statement_no,
'total_amount' => $statement->total_amount,
'settlement_date' => $statement->settlement_date?->toDateString(),
], '对账单已生成');
}
/** 对账单详情(校验归属,含单品对账状态标识) */
#[GetRoute('/statement/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::with('items')
->where('store_id', $store->id)
->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return $this->success($statement->toArray());
}
/** 导出对账单:?format=xlsx|pdf */
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::where('store_id', $store->id)->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return app(ExportService::class)->download(
'statement',
$statement,
(string) $request->query('format', ExportService::FORMAT_XLSX),
);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Mini;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店设置(回款周期自配置,影响对账单应结算日期)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class StoreController extends BaseMiniController
{
/** 修改回款周期(≥0,无上限) */
#[PutRoute('/store/paymentCycle', authorize: true)]
public function paymentCycle(Request $request): JsonResponse
{
$data = $request->validate([
'payment_cycle_days' => 'required|integer|min:0',
], [
'payment_cycle_days.required' => '回款周期不能为空',
'payment_cycle_days.integer' => '回款周期必须为整数',
'payment_cycle_days.min' => '回款周期不能小于 0',
]);
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$store->payment_cycle_days = (int) $data['payment_cycle_days'];
$store->save();
return $this->success(['payment_cycle_days' => $store->payment_cycle_days], '回款周期已更新');
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序供应商端(接收采购单 / 明细 / 确认接单)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class SupplierController extends BaseMiniController
{
/** 收到的采购单:含本供应商 is_sent=1 明细的采购单(去重) */
#[GetRoute('/supplier/purchases', authorize: true)]
public function purchases(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchaseIds = PurchaseOrderItemModel::query()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->distinct()
->pluck('purchase_id');
$data = PurchaseOrderModel::query()
->whereIn('id', $purchaseIds)
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 采购单明细:仅本供应商且已发送的明细行 */
#[GetRoute('/supplier/purchases/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
return $this->success([
'id' => $purchase->id,
'purchase_no' => $purchase->purchase_no,
'purchase_date' => $purchase->purchase_date?->toDateString(),
'remark' => $purchase->remark,
'items' => $items->toArray(),
]);
}
/** 确认接单:本供应商已发送明细批量记录 supplier_confirmed_at(幂等) */
#[PutRoute('/supplier/purchases/{id}/confirm', authorize: true, where: ['id' => '[0-9]+'])]
public function confirm(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
$now = now();
$confirmed = 0;
foreach ($items as $item) {
if ($item->supplier_confirmed_at === null) {
$item->supplier_confirmed_at = $now;
$item->save();
$confirmed++;
}
}
return $this->success(['confirmed' => $confirmed], '已确认接单');
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Order;
use App\Exceptions\RepositoryException;
use App\Models\NoticeModel;
use App\Models\ProductModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\UserModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 门店订单管理(订单只读 + 状态管理;创建/取消在小程序端)
*/
#[RequestAttribute('/order/store', 'order.store')]
class StoreOrderController extends BaseController
{
/** 状态中文名(通知文案用) */
private const STATUS_NAMES = [
StoreOrderModel::STATUS_PENDING => '待汇总',
StoreOrderModel::STATUS_SUMMARIZED => '已汇总',
StoreOrderModel::STATUS_DELIVERING => '配送中',
StoreOrderModel::STATUS_COMPLETED => '已完成',
StoreOrderModel::STATUS_CANCELLED => '已取消',
];
protected array $searchField = [
'store_id' => '=',
'status' => '=',
'order_no' => 'like',
'order_date' => 'betweenDate',
];
/** 订单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StoreOrderModel::query()->with('store:id,name'))
->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 待汇总预览:聚合所有待汇总订单明细(按商品分组),
* 供生成采购单前确认(C1 前置)
*/
#[GetRoute('/summary', 'query')]
public function summary(): JsonResponse
{
$rows = StoreOrderItemModel::query()
->select('product_id')
->selectRaw('MAX(product_name) as product_name')
->selectRaw('MAX(product_spec) as product_spec')
->selectRaw('SUM(quantity) as total_quantity')
->selectRaw('COUNT(DISTINCT store_id) as store_count')
->whereHas('order', function ($query) {
$query->where('status', StoreOrderModel::STATUS_PENDING);
})
->groupBy('product_id')
->orderBy('product_id')
->get()
->toArray();
// 补充计价单位(商品档案,含已下架/软删除)
$units = ProductModel::withTrashed()
->whereIn('id', array_column($rows, 'product_id'))
->pluck('unit', 'id');
foreach ($rows as &$row) {
$row['unit'] = $units[$row['product_id']] ?? '';
}
return $this->success($rows);
}
/** 订单详情:订单头 + 明细(含商品快照) */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$order = StoreOrderModel::with(['store:id,name', 'items'])->find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
return $this->success($order->toArray());
}
/**
* 状态流转(待汇总→配送中→完成;待汇总可取消;已汇总可转配送中)
*/
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'status' => 'required|integer|in:2,3,9',
], [
'status.required' => '目标状态不能为空',
'status.in' => '目标状态值不正确',
]);
$order = StoreOrderModel::find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$target = (int) $data['status'];
$allowed = match ($order->status) {
StoreOrderModel::STATUS_PENDING => [
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_CANCELLED,
],
StoreOrderModel::STATUS_SUMMARIZED => [
StoreOrderModel::STATUS_DELIVERING,
],
StoreOrderModel::STATUS_DELIVERING => [
StoreOrderModel::STATUS_COMPLETED,
],
default => [],
};
if (! in_array($target, $allowed, true)) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作'
);
}
$order->status = $target;
$order->save();
$this->notifyStore($order);
return $this->success();
}
/**
* 状态流转后通知门店用户
*/
private function notifyStore(StoreOrderModel $order): void
{
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('store_id', $order->store_id)
->where('status', UserModel::STATUS_NORMAL)
->pluck('id');
$statusName = self::STATUS_NAMES[$order->status] ?? (string) $order->status;
foreach ($userIds as $userId) {
NoticeModel::create([
'user_id' => $userId,
'type' => NoticeModel::TYPE_ORDER,
'title' => '订单状态更新',
'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}", 0, 500),
'data' => ['order_id' => $order->id],
'is_read' => NoticeModel::UNREAD,
]);
}
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Product;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Product\ProductCategoryFormRequest;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 商品分类管理(多级分类:蔬菜/水果/其他)
*/
#[RequestAttribute('/product/category', 'product.category')]
class ProductCategoryController extends BaseController
{
/** 分类树列表(后端组装 children,前端树表展示) */
#[GetRoute(authorize: 'query')]
public function query(): JsonResponse
{
return $this->success(ProductCategoryModel::getTreeData());
}
/** 级联选项(商品表单分类下拉、对账筛选用;仅启用分类) */
#[GetRoute('/tree', 'query')]
public function tree(): JsonResponse
{
return $this->success(ProductCategoryModel::getTreeData(onlyEnabled: true));
}
/** 创建分类 */
#[PostRoute(authorize: 'create')]
public function create(ProductCategoryFormRequest $request): JsonResponse
{
$validated = $request->validated();
$parentId = (int) $validated['parent_id'];
if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) {
throw new RepositoryException('父级分类不存在');
}
ProductCategoryModel::create($validated);
return $this->success();
}
/** 编辑分类(防自引用成环) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ProductCategoryFormRequest $request): JsonResponse
{
$model = ProductCategoryModel::find($id);
if (empty($model)) {
throw new RepositoryException('分类不存在');
}
$validated = $request->validated();
$this->assertNoCycle($id, (int) $validated['parent_id']);
$model->update($validated);
return $this->success();
}
/** 删除分类(有子分类或挂载商品时拒绝) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = ProductCategoryModel::find($id);
if (empty($model)) {
throw new RepositoryException('分类不存在');
}
if (ProductCategoryModel::where('parent_id', $id)->exists()) {
throw new RepositoryException('该分类下存在子分类,无法删除');
}
if (ProductModel::where('category_id', $id)->exists()) {
throw new RepositoryException('该分类下存在商品,无法删除');
}
$model->delete();
return $this->success();
}
/**
* 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环
*/
private function assertNoCycle(int $id, int $parentId): void
{
if ($parentId === 0) {
return;
}
if ($parentId === $id) {
throw new RepositoryException('父级分类不能是自身');
}
$cursor = $parentId;
$guard = 0;
while ($cursor > 0 && $guard++ < 100) {
$next = ProductCategoryModel::whereKey($cursor)->value('parent_id');
if ($next === null) {
throw new RepositoryException('父级分类不存在');
}
if ((int) $next === $id) {
throw new RepositoryException('父级分类不能是子级分类,会形成循环');
}
$cursor = (int) $next;
}
}
}
@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Product;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Product\BatchPriceRequest;
use App\Http\Requests\Product\ProductFormRequest;
use App\Models\CustomerLevelModel;
use App\Models\NoticeModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\StoreModel;
use App\Models\UserModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价 / 多等级价格体系)
*/
#[RequestAttribute('/product/goods', 'product.goods')]
class ProductController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'category_id' => '=',
'supplier_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['name', 'spec'];
/** A1 商品列表(含分类/供应商/各等级价格) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch(
$params,
ProductModel::query()->with(['category:id,name', 'supplier:id,name', 'prices.level:id,name'])
)
->orderBy('sort')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建商品(事务内建商品 + 同步等级价格) */
#[PostRoute(authorize: 'create')]
public function create(ProductFormRequest $request): JsonResponse
{
$validated = $request->validated();
$prices = $validated['prices'] ?? [];
unset($validated['prices']);
$product = DB::transaction(function () use ($validated, $prices) {
$product = ProductModel::create($validated);
foreach ($prices as $row) {
ProductPriceModel::create([
'product_id' => $product->id,
'level_id' => (int) $row['level_id'],
'price' => $row['price'],
]);
}
return $product;
});
return $this->success(['id' => $product->id]);
}
/** 编辑商品(prices 按 level_id upsert,删除已移除的等级行;未提交 prices 键时保持原价) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ProductFormRequest $request): JsonResponse
{
$product = ProductModel::find($id);
if (empty($product)) {
throw new RepositoryException('商品不存在');
}
$validated = $request->validated();
$prices = $validated['prices'] ?? [];
unset($validated['prices']);
DB::transaction(function () use ($product, $validated, $prices, $request) {
$product->update($validated);
if ($request->has('prices')) {
$levelIds = [];
foreach ($prices as $row) {
$levelId = (int) $row['level_id'];
$levelIds[] = $levelId;
ProductPriceModel::updateOrCreate(
['product_id' => $product->id, 'level_id' => $levelId],
['price' => $row['price']],
);
}
$product->prices()->whereNotIn('level_id', $levelIds)->delete();
}
});
return $this->success();
}
/** 删除商品(软删除,连带价格行一并删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$product = ProductModel::find($id);
if (empty($product)) {
throw new RepositoryException('商品不存在');
}
DB::transaction(function () use ($product) {
$product->prices()->delete();
$product->delete();
});
return $this->success();
}
/**
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤),列=全部启用等级,值=price(缺失为 null
*/
#[GetRoute('/priceMatrix', 'query')]
public function priceMatrix(Request $request): JsonResponse
{
$query = ProductModel::query()->with('prices:id,product_id,level_id,price');
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
$query->where('category_id', $categoryId);
}
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%')
->orWhere('spec', 'like', '%' . $keyword . '%');
});
}
$products = $query->orderBy('sort')->orderBy('id')->get();
$levels = CustomerLevelModel::query()
->where('status', CustomerLevelModel::STATUS_NORMAL)
->orderBy('sort')
->get(['id', 'name']);
$rows = $products->map(function (ProductModel $product) use ($levels) {
$priceMap = $product->prices->keyBy('level_id');
$row = [
'id' => $product->id,
'name' => $product->name,
'spec' => $product->spec,
'unit' => $product->unit,
];
foreach ($levels as $level) {
$row['price_' . $level->id] = isset($priceMap[$level->id])
? (float) $priceMap[$level->id]->price
: null;
}
return $row;
});
return $this->success([
'levels' => $levels->toArray(),
'rows' => $rows->toArray(),
]);
}
/**
* A2 批量调价:事务写入,写完后给受影响门店生成 Noticetype=price
*/
#[PutRoute('/batchPrice', 'batchPrice')]
public function batchPrice(BatchPriceRequest $request): JsonResponse
{
$updates = $request->validated('updates');
DB::transaction(function () use ($updates) {
$productIds = [];
$levelIds = [];
foreach ($updates as $row) {
ProductPriceModel::updateOrCreate(
['product_id' => (int) $row['product_id'], 'level_id' => (int) $row['level_id']],
['price' => $row['price']],
);
$productIds[(int) $row['product_id']] = true;
$levelIds[(int) $row['level_id']] = true;
}
$productNames = ProductModel::whereIn('id', array_keys($productIds))
->pluck('name')
->implode('、');
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
// 受影响门店:客户等级在本次调价等级范围内的正常门店,通知其绑定的正常用户
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('status', UserModel::STATUS_NORMAL)
->whereIn('store_id', function ($q) use ($levelIds) {
$q->select('id')
->from('store')
->where('status', StoreModel::STATUS_NORMAL)
->whereIn('level_id', array_keys($levelIds));
})
->pluck('id');
foreach ($userIds as $userId) {
NoticeModel::create([
'user_id' => $userId,
'type' => NoticeModel::TYPE_PRICE,
'title' => '商品价格变更',
'content' => $content,
'data' => [
'product_ids' => array_keys($productIds),
'level_ids' => array_keys($levelIds),
],
'is_read' => NoticeModel::UNREAD,
]);
}
});
return $this->success();
}
/** 商品下拉选项(仅上架,下单等场景用) */
#[GetRoute('/options', 'query')]
public function options(Request $request): JsonResponse
{
$query = ProductModel::query()->where('status', ProductModel::STATUS_ON);
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where('name', 'like', '%' . $keyword . '%');
}
$data = $query->orderBy('sort')
->get(['id', 'name', 'spec', 'unit'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Purchase\PurchaseItemUpdateRequest;
use App\Models\PurchaseAllocationModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use App\Services\ExportService;
use App\Services\PurchaseAllocateService;
use App\Services\PurchaseGenerateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改 / C5-C6 发送供应商 / D3 金额分摊)
*/
#[RequestAttribute('/purchase/order', 'purchase.order')]
class PurchaseOrderController extends BaseController
{
protected array $searchField = [
'status' => '=',
'purchase_no' => 'like',
'purchase_date' => 'betweenDate',
];
/** 采购单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 采购单详情:头 + 明细(含供应商)+ 分摊记录 */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::with([
'operator:id,nickname',
'items.supplier:id,name',
'items.allocations.store:id,name',
])->find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
return $this->success($purchase->toArray());
}
/** C4 修改采购单头信息(采购日期、备注) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'purchase_date' => 'nullable|date_format:Y-m-d',
'remark' => 'nullable|string|max:255',
], [
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
]);
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
return $this->success();
}
/** C1 按门店订单汇总生成采购单 */
#[PostRoute('/generate', 'generate')]
public function generate(Request $request): JsonResponse
{
$data = $request->validate([
'purchase_date' => 'required|date_format:Y-m-d',
], [
'purchase_date.required' => '请选择采购日期',
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
]);
$purchase = app(PurchaseGenerateService::class)->generate(
$data['purchase_date'],
(int) $request->user()->id,
);
return $this->success(
['id' => $purchase->id, 'purchase_no' => $purchase->purchase_no],
'采购单已生成'
);
}
/** C2/C3 导出采购单:?type=all|category & format=xlsx|pdf */
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$type = (string) $request->query('type', 'all');
if (! in_array($type, ['all', 'category'], true)) {
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
}
return app(ExportService::class)->download(
'purchase',
$purchase,
(string) $request->query('format', ExportService::FORMAT_XLSX),
type: $type,
);
}
/**
* C4 采购明细修改:amount 后端重算(weight>0 ? weight×price : quantity×price),
* 同步回写采购单头汇总(Σ total_weight / actual_amount
*/
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function updateItem(int $id, PurchaseItemUpdateRequest $request): JsonResponse
{
$item = PurchaseOrderItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('采购明细不存在');
}
$validated = $request->validated();
$price = (string) $validated['price'];
$quantity = (string) $validated['quantity'];
$weight = (string) ($validated['weight'] ?? 0);
$amount = (float) $weight > 0
? bcmul($weight, $price, 2)
: bcmul($quantity, $price, 2);
$item->update([
'product_name' => $validated['product_name'] ?? $item->product_name,
'product_spec' => $validated['product_spec'] ?? $item->product_spec,
'price' => $price,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'remark' => $validated['remark'] ?? $item->remark,
]);
// 回写采购单头汇总
$sums = PurchaseOrderItemModel::query()
->where('purchase_id', $item->purchase_id)
->selectRaw('COALESCE(SUM(weight), 0) as total_weight, COALESCE(SUM(amount), 0) as actual_amount')
->first();
PurchaseOrderModel::whereKey($item->purchase_id)->update([
'total_weight' => $sums->total_weight,
'actual_amount' => $sums->actual_amount,
]);
return $this->success(['amount' => $amount]);
}
/** C5/C6 明细发送供应商:is_sent=1 + sent_at;联动采购单状态(全发送→ALL_SENT,否则 PART_SENT */
#[PutRoute(route: '/item/{id}/send', authorize: 'send', where: ['id' => '[0-9]+'])]
public function sendItem(int $id): JsonResponse
{
$item = PurchaseOrderItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('采购明细不存在');
}
if ($item->is_sent === PurchaseOrderItemModel::SENT) {
throw new RepositoryException('该明细已发送,请勿重复操作');
}
$item->is_sent = PurchaseOrderItemModel::SENT;
$item->sent_at = now();
$item->save();
$purchase = $item->purchase;
$hasUnsent = $purchase->items()
->where('is_sent', PurchaseOrderItemModel::NOT_SENT)
->exists();
$purchase->status = $hasUnsent
? PurchaseOrderModel::STATUS_PART_SENT
: PurchaseOrderModel::STATUS_ALL_SENT;
$purchase->save();
return $this->success();
}
/** D3 执行金额分摊(按订货比例摊到门店/单品,尾差修正守恒;可重复执行) */
#[PostRoute(route: '/{id}/allocate', authorize: 'allocate', where: ['id' => '[0-9]+'])]
public function allocate(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$count = app(PurchaseAllocateService::class)->allocate($purchase);
return $this->success(['count' => $count], '分摊完成');
}
/** 分摊结果:按门店、按商品两个聚合维度 */
#[GetRoute(route: '/{id}/allocation', authorize: 'query', where: ['id' => '[0-9]+'])]
public function allocation(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$allocations = PurchaseAllocationModel::query()
->whereIn('purchase_item_id', $purchase->items()->pluck('id'))
->with(['store:id,name', 'product:id,name,unit'])
->get();
$byStore = $allocations->groupBy('store_id')->map(function ($group) {
$first = $group->first();
return [
'store_id' => $first->store_id,
'store_name' => $first->store?->name ?? '',
'quantity' => (float) $group->sum('quantity'),
'weight' => (float) $group->sum('weight'),
'amount' => (float) $group->sum('amount'),
];
})->values();
$byProduct = $allocations->groupBy('product_id')->map(function ($group) {
$first = $group->first();
return [
'product_id' => $first->product_id,
'product_name' => $first->product?->name ?? '',
'unit' => $first->product?->unit ?? '',
'quantity' => (float) $group->sum('quantity'),
'weight' => (float) $group->sum('weight'),
'amount' => (float) $group->sum('amount'),
];
})->values();
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total_amount' => (float) $allocations->sum('amount'),
]);
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconItemUpdateRequest;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 对账明细操作(D4 修改 / D6 单品级门店备注 / D8 对账状态标记)
* 权限点前缀 recon.itemauthorize: item.update → recon.item.item.update
*/
#[RequestAttribute('/recon/item', 'recon.item')]
class ReconItemController extends BaseController
{
/**
* D4 修改订货量/称重/数量/金额/商品名,自动重算本行 diff + 头汇总
*/
#[PutRoute(route: '/{id}', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconItemUpdateRequest $request): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$validated = $request->validated();
if (isset($validated['product_name'])) {
$item->product_name = $validated['product_name'];
}
if (isset($validated['quantity'])) {
$item->quantity = $validated['quantity'];
}
if (isset($validated['weight'])) {
$item->weight = $validated['weight'];
}
if (isset($validated['publish_amount'])) {
$item->publish_amount = $validated['publish_amount'];
}
if (isset($validated['actual_amount'])) {
$item->actual_amount = $validated['actual_amount'];
}
// 重算本行差额
$item->diff_amount = bcsub((string) $item->publish_amount, (string) $item->actual_amount, 2);
$item->save();
$this->refreshReconSummary((int) $item->recon_id);
return $this->success(['diff_amount' => $item->diff_amount]);
}
/** D8 对账状态标记翻转 */
#[PutRoute(route: '/{id}/toggle', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function toggle(int $id): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->is_reconciled = $item->is_reconciled === ReconciliationItemModel::RECONCILED
? ReconciliationItemModel::NOT_RECONCILED
: ReconciliationItemModel::RECONCILED;
$item->save();
return $this->success(['is_reconciled' => $item->is_reconciled]);
}
/** D6 单品级门店备注 */
#[PutRoute(route: '/{id}/remark', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function remark(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'store_remark' => 'nullable|string|max:255',
], [
'store_remark.max' => '备注最长 255 个字符',
]);
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->store_remark = (string) ($data['store_remark'] ?? '');
$item->save();
return $this->success();
}
/**
* 已结算的对账单明细不允许修改
*/
private function assertEditable(ReconciliationItemModel $item): void
{
$recon = ReconciliationModel::find($item->recon_id);
if ($recon !== null && $recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,明细不能修改');
}
}
/**
* 明细变更后重算对账单头汇总(publish / actual / diff
*/
private function refreshReconSummary(int $reconId): void
{
$sums = ReconciliationItemModel::query()
->where('recon_id', $reconId)
->selectRaw('COALESCE(SUM(publish_amount), 0) as publish_total, COALESCE(SUM(actual_amount), 0) as actual_total')
->first();
ReconciliationModel::whereKey($reconId)->update([
'publish_amount' => $sums->publish_total,
'actual_amount' => $sums->actual_total,
'diff_amount' => bcsub((string) $sums->publish_total, (string) $sums->actual_total, 2),
]);
}
}
@@ -0,0 +1,235 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconciliationFormRequest;
use App\Models\ReconciliationModel;
use App\Models\SettlementModel;
use App\Services\BillNumberService;
use App\Services\ReconciliationBuildService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 财务对账管理(D1 品类 / D2 供应商筛选、D5 差额对比、D9 结算表生成)
* 对账明细的 D4/D6/D8 操作见 ReconItemController
*/
#[RequestAttribute('/recon/list', 'recon.list')]
class ReconciliationController extends BaseController
{
protected array $searchField = [
'status' => '=',
'category_id' => '=',
'supplier_id' => '=',
'title' => 'like',
'period_start' => 'date',
];
/** 对账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, ReconciliationModel::query()->with('operator:id,nickname'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建对账单(草稿,recon_no = RC…) */
#[PostRoute(authorize: 'create')]
public function create(ReconciliationFormRequest $request): JsonResponse
{
$validated = $request->validated();
$recon = ReconciliationModel::create([
'recon_no' => app(BillNumberService::class)->make('RC'),
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'publish_amount' => 0,
'actual_amount' => 0,
'diff_amount' => 0,
'status' => ReconciliationModel::STATUS_DRAFT,
'operator_id' => (int) $request->user()->id,
'remark' => $validated['remark'] ?? '',
]);
return $this->success(['id' => $recon->id]);
}
/** 编辑对账单(仅草稿/对账中) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconciliationFormRequest $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能编辑');
}
$validated = $request->validated();
$recon->update([
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'remark' => $validated['remark'] ?? '',
]);
return $this->success();
}
/** 删除对账单(仅草稿可删,连带明细) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_DRAFT) {
throw new RepositoryException('仅草稿状态的对账单可以删除');
}
DB::transaction(function () use ($recon) {
$recon->items()->delete();
$recon->delete();
});
return $this->success();
}
/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据;可重复生成) */
#[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])]
public function build(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能重新生成明细');
}
$count = app(ReconciliationBuildService::class)->build($recon);
return $this->success(['count' => $count], '对账明细已生成');
}
/**
* D5 差额对比视图:按门店 / 按商品两个维度 + 合计行
*/
#[GetRoute(route: '/{id}/diff', authorize: 'query', where: ['id' => '[0-9]+'])]
public function diff(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
$items = $recon->items()->with('store:id,name')->get();
$byStore = $items->groupBy('store_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'store_id' => $first->store_id,
'store_name' => $first->store?->name ?? '',
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$byProduct = $items->groupBy('product_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'product_id' => $first->product_id,
'product_name' => $first->product_name,
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$publishTotal = (string) $items->sum('publish_amount');
$actualTotal = (string) $items->sum('actual_amount');
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total' => [
'publish' => (float) $publishTotal,
'actual' => (float) $actualTotal,
'diff' => (float) bcsub($publishTotal, $actualTotal, 2),
],
]);
}
/**
* D9 生成结算表:按门店聚合明细生成 settlement 记录,对账单 status → 已结算
* (回框统计表规则待业务确认,本次仅预留结构)
*/
#[PostRoute(route: '/{id}/settle', authorize: 'settle', where: ['id' => '[0-9]+'])]
public function settle(int $id, Request $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_WORKING) {
throw new RepositoryException('仅「对账中」的对账单可以生成结算表');
}
$count = DB::transaction(function () use ($recon, $request) {
$groups = $recon->items()->get()->groupBy('store_id');
if ($groups->isEmpty()) {
throw new RepositoryException('对账单无明细,请先生成对账明细');
}
$billNumber = app(BillNumberService::class);
foreach ($groups as $storeId => $items) {
$publish = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->publish_amount, 2),
'0'
);
$actual = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->actual_amount, 2),
'0'
);
SettlementModel::create([
'settlement_no' => $billNumber->make('JS'),
'recon_id' => $recon->id,
'store_id' => (int) $storeId,
'period_start' => $recon->period_start,
'period_end' => $recon->period_end,
'total_amount' => $publish,
'actual_amount' => $actual,
'diff_amount' => bcsub($publish, $actual, 2),
'status' => SettlementModel::STATUS_SETTLED,
'operator_id' => (int) $request->user()->id,
'settled_at' => now(),
]);
}
$recon->status = ReconciliationModel::STATUS_SETTLED;
$recon->save();
return $groups->count();
});
return $this->success(['count' => $count], '结算表已生成');
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\SettlementModel;
use App\Services\ExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
*/
#[RequestAttribute('/recon/settlement', 'recon.settlement')]
class SettlementController extends BaseController
{
protected array $searchField = [
'settlement_no' => 'like',
'recon_id' => '=',
'store_id' => '=',
'status' => '=',
];
/** 结算表列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch(
$params,
SettlementModel::query()->with(['store:id,name', 'recon:id,recon_no,title'])
)
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 结算表详情 */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$settlement = SettlementModel::with(['store:id,name', 'recon:id,recon_no,title', 'operator:id,nickname'])
->find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
return $this->success($settlement->toArray());
}
/**
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
*/
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
public function download(int $id, Request $request): Response
{
$settlement = SettlementModel::find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
$response = app(ExportService::class)->download('settlement', $settlement, $format);
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
$settlement->save();
return $response;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\StatementModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 门店对账单管理(后台只读视角;生成/导出在小程序端)
*/
#[RequestAttribute('/recon/statement', 'recon.statement')]
class StatementController extends BaseController
{
protected array $searchField = [
'statement_no' => 'like',
'store_id' => '=',
'status' => '=',
'period_start' => 'betweenDate',
];
/** 对账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StatementModel::query()->with('store:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 对账单详情(含明细) */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$statement = StatementModel::with(['store:id,name', 'items'])->find($id);
if (empty($statement)) {
throw new RepositoryException('对账单不存在');
}
return $this->success($statement->toArray());
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Customer;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 客户等级 创建/编辑 验证
*/
class CustomerLevelFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$unique = Rule::unique('customer_level', 'name');
if ($this->isUpdate()) {
$unique = $unique->ignore($this->route('id'));
}
return [
'name' => ['required', 'string', 'max:50', $unique],
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '等级名称不能为空',
'name.max' => '等级名称最长 50 个字符',
'name.unique' => '等级名称已存在',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 小程序用户绑定 验证(type=1 门店需 store_idtype=2 供应商需 supplier_id
*/
class MiniUserBindRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'type' => 'required|integer|in:1,2',
'store_id' => 'required_if:type,1|nullable|integer|min:1',
'supplier_id' => 'required_if:type,2|nullable|integer|min:1',
];
}
public function messages(): array
{
return [
'type.required' => '请选择用户类型',
'type.in' => '用户类型只能是门店或供应商',
'store_id.required_if' => '绑定门店时必须选择门店',
'store_id.min' => '门店ID不正确',
'supplier_id.required_if' => '绑定供应商时必须选择供应商',
'supplier_id.min' => '供应商ID不正确',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 通知 创建 验证(user_id 留空 = 全员广播)
*/
class NoticeFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge(['user_id' => (int) ($this->input('user_id') ?? 0)]);
}
public function rules(): array
{
return [
'user_id' => 'required|integer|min:0',
'type' => 'required|string|in:order,price,system',
'title' => 'required|string|max:100',
'content' => 'nullable|string|max:500',
];
}
public function messages(): array
{
return [
'type.required' => '通知类型不能为空',
'type.in' => '通知类型只能是 order/price/system',
'title.required' => '通知标题不能为空',
'title.max' => '通知标题最长 100 个字符',
'content.max' => '通知内容最长 500 个字符',
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Customer;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 门店 创建/编辑 验证
*/
class StoreFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$unique = Rule::unique('store', 'code');
if ($this->isUpdate()) {
$unique = $unique->ignore($this->route('id'));
}
return [
'name' => 'required|string|max:100',
'code' => ['required', 'string', 'max:50', $unique],
'level_id' => 'required|integer|exists:customer_level,id',
'contact' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'payment_cycle_days' => 'nullable|integer|min:0',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '门店名称不能为空',
'name.max' => '门店名称最长 100 个字符',
'code.required' => '门店编码不能为空',
'code.unique' => '门店编码已存在',
'level_id.required' => '请选择客户等级',
'level_id.exists' => '客户等级不存在',
'payment_cycle_days.integer' => '回款周期必须为整数',
'payment_cycle_days.min' => '回款周期不能小于 0',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 供应商 创建/编辑 验证
*/
class SupplierFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'contact' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'main_products' => 'nullable|string|max:255',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '供应商名称不能为空',
'name.max' => '供应商名称最长 100 个字符',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Mini;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 小程序下单 验证(金额一律服务端重算,前端不传金额字段)
*/
class MiniOrderRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:product,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'items.required' => '请至少选择一件商品',
'items.min' => '请至少选择一件商品',
'items.*.product_id.required' => '下单商品不能为空',
'items.*.product_id.exists' => '商品不存在',
'items.*.quantity.required' => '订货数量不能为空',
'items.*.quantity.numeric' => '订货数量必须为数字',
'items.*.quantity.min' => '订货数量必须大于 0',
'remark.max' => '订单备注最长 255 个字符',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 批量调价 验证(A2 价格矩阵编辑提交)
*/
class BatchPriceRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'updates' => 'required|array|min:1',
'updates.*.product_id' => 'required|integer|exists:product,id',
'updates.*.level_id' => 'required|integer|exists:customer_level,id',
'updates.*.price' => 'required|numeric|min:0',
];
}
public function messages(): array
{
return [
'updates.required' => '请至少提交一条价格调整',
'updates.min' => '请至少提交一条价格调整',
'updates.*.product_id.required' => '调价行缺少商品',
'updates.*.product_id.exists' => '商品不存在',
'updates.*.level_id.required' => '调价行缺少客户等级',
'updates.*.level_id.exists' => '客户等级不存在',
'updates.*.price.required' => '调价行缺少单价',
'updates.*.price.numeric' => '单价必须为数字',
'updates.*.price.min' => '单价不能小于 0',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 商品分类 创建/编辑 验证
*/
class ProductCategoryFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge(['parent_id' => (int) ($this->input('parent_id') ?? 0)]);
}
public function rules(): array
{
return [
'name' => 'required|string|max:50',
'parent_id' => 'required|integer|min:0',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
];
}
public function messages(): array
{
return [
'name.required' => '分类名称不能为空',
'name.max' => '分类名称最长 50 个字符',
'parent_id.min' => '父级分类ID不正确',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
*/
class ProductFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'spec' => 'nullable|string|max:100',
'grade' => 'nullable|string|max:50',
'unit' => 'nullable|string|max:20',
'category_id' => 'required|integer|exists:product_category,id',
'supplier_id' => 'nullable|integer|exclude_if:supplier_id,0|exists:supplier,id',
'image' => 'nullable|string|max:255',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
'prices' => 'nullable|array',
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
'prices.*.price' => 'required|numeric|min:0',
];
}
public function messages(): array
{
return [
'name.required' => '商品名称不能为空',
'name.max' => '商品名称最长 100 个字符',
'category_id.required' => '请选择商品分类',
'category_id.exists' => '商品分类不存在',
'supplier_id.exists' => '供应商不存在',
'status.in' => '状态值不正确',
'prices.*.level_id.required' => '价格行缺少客户等级',
'prices.*.level_id.exists' => '客户等级不存在',
'prices.*.price.required' => '价格行缺少单价',
'prices.*.price.numeric' => '单价必须为数字',
'prices.*.price.min' => '单价不能小于 0',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购明细修改 验证(C4amount 由后端按 weight>0 ? weight×price : quantity×price 重算)
*/
class PurchaseItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'product_spec' => 'nullable|string|max:100',
'price' => 'required|numeric|min:0',
'quantity' => 'required|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'price.required' => '采购单价不能为空',
'price.numeric' => '采购单价必须为数字',
'price.min' => '采购单价不能小于 0',
'quantity.required' => '采购量不能为空',
'quantity.numeric' => '采购量必须为数字',
'quantity.min' => '采购量不能小于 0',
'weight.numeric' => '实际称重必须为数字',
'weight.min' => '实际称重不能小于 0',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 对账明细修改 验证(D4:订货量/称重/数量/金额/商品信息;diff 与头汇总由后端重算)
*/
class ReconItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'quantity' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
'publish_amount' => 'nullable|numeric|min:0',
'actual_amount' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'quantity.numeric' => '订货量必须为数字',
'quantity.min' => '订货量不能小于 0',
'weight.numeric' => '称重必须为数字',
'weight.min' => '称重不能小于 0',
'publish_amount.numeric' => '公布金额必须为数字',
'publish_amount.min' => '公布金额不能小于 0',
'actual_amount.numeric' => '实际金额必须为数字',
'actual_amount.min' => '实际金额不能小于 0',
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 财务对账单 创建/编辑 验证
*/
class ReconciliationFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge([
'category_id' => (int) ($this->input('category_id') ?? 0),
'supplier_id' => (int) ($this->input('supplier_id') ?? 0),
]);
}
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'period_start' => 'required|date_format:Y-m-d',
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
'category_id' => 'required|integer|min:0',
'supplier_id' => 'required|integer|min:0',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'title.required' => '对账标题不能为空',
'title.max' => '对账标题最长 100 个字符',
'period_start.required' => '请选择对账周期开始日期',
'period_start.date_format' => '开始日期格式为 Y-m-d',
'period_end.required' => '请选择对账周期结束日期',
'period_end.date_format' => '结束日期格式为 Y-m-d',
'period_end.after_or_equal' => '结束日期不能早于开始日期',
];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 客户等级模型(同一商品按客户等级定价)
*/
class CustomerLevelModel extends Model
{
use HasFactory;
/** 状态:停用 */
public const STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
protected $table = 'customer_level';
protected $primaryKey = 'id';
protected $fillable = [
'name',
'sort',
'status',
'remark',
];
protected $casts = [
'sort' => 'integer',
'status' => 'integer',
];
/**
* 该等级下的门店
*/
public function stores(): HasMany
{
return $this->hasMany(StoreModel::class, 'level_id', 'id');
}
/**
* 该等级下的商品价格
*/
public function prices(): HasMany
{
return $this->hasMany(ProductPriceModel::class, 'level_id', 'id');
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 通知模型(小程序端消息:订单 / 价格变更 / 系统;user_id=0 为全员广播)
*/
class NoticeModel extends Model
{
/** 通知类型:订单 */
public const TYPE_ORDER = 'order';
/** 通知类型:价格变更 */
public const TYPE_PRICE = 'price';
/** 通知类型:系统 */
public const TYPE_SYSTEM = 'system';
/** 未读 */
public const UNREAD = 0;
/** 已读 */
public const READ = 1;
/** 全员广播时的 user_id 约定值 */
public const BROADCAST_USER_ID = 0;
protected $table = 'notice';
protected $primaryKey = 'id';
protected $fillable = [
'user_id',
'type',
'title',
'content',
'data',
'is_read',
'read_at',
];
protected $casts = [
'data' => 'array',
'is_read' => 'integer',
'user_id' => 'integer',
'read_at' => 'datetime',
];
/**
* 接收用户(user_id=0 表示全员广播,无对应用户)
*/
public function user(): BelongsTo
{
return $this->belongsTo(UserModel::class, 'user_id', 'id');
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 商品分类模型(蔬菜/水果/其他,多级分类自关联)
*/
class ProductCategoryModel extends Model
{
/** 状态:停用 */
public const STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
protected $table = 'product_category';
protected $primaryKey = 'id';
protected $fillable = [
'parent_id',
'name',
'sort',
'status',
];
protected $casts = [
'parent_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
];
/**
* 父分类
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id', 'id');
}
/**
* 子分类
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id', 'id')->orderBy('sort');
}
/**
* 分类下的商品
*/
public function products(): HasMany
{
return $this->hasMany(ProductModel::class, 'category_id', 'id');
}
/**
* 获取分类树(树表展示 / 级联下拉选项复用)
*
* @param bool $onlyEnabled 是否仅返回启用分类
* @return array<int, array{id: int, name: string, parent_id: int, sort: int, status: int, children?: array}>
*/
public static function getTreeData(bool $onlyEnabled = false): array
{
$query = static::query()->orderBy('sort')->orderBy('id');
if ($onlyEnabled) {
$query->where('status', self::STATUS_NORMAL);
}
return static::buildTree($query->get()->toArray());
}
/**
* 将扁平分类列表组装为树
*
* @param array<int, array<string, mixed>> $items
* @param int $parentId
* @return array<int, array<string, mixed>>
*/
public static function buildTree(array $items, int $parentId = 0): array
{
$tree = [];
foreach ($items as $item) {
if ((int) $item['parent_id'] !== $parentId) {
continue;
}
$children = static::buildTree($items, (int) $item['id']);
if ($children !== []) {
$item['children'] = $children;
}
$tree[] = $item;
}
return $tree;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 商品档案模型(品名/规格包规/供应商/等级/多等级价格体系)
*/
class ProductModel extends Model
{
use SoftDeletes, HasFactory;
/** 状态:下架 */
public const STATUS_OFF = 0;
/** 状态:上架 */
public const STATUS_ON = 1;
protected $table = 'product';
protected $primaryKey = 'id';
protected $fillable = [
'category_id',
'supplier_id',
'name',
'spec',
'grade',
'unit',
'image',
'sort',
'status',
'remark',
];
protected $casts = [
'category_id' => 'integer',
'supplier_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
];
/**
* 所属分类
*/
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategoryModel::class, 'category_id', 'id');
}
/**
* 供货供应商
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
}
/**
* 多等级价格(同一商品按客户等级定价)
*/
public function prices(): HasMany
{
return $this->hasMany(ProductPriceModel::class, 'product_id', 'id');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id
*/
class ProductPriceModel extends Model
{
use HasFactory;
protected $table = 'product_price';
protected $primaryKey = 'id';
protected $fillable = [
'product_id',
'level_id',
'price',
];
protected $casts = [
'product_id' => 'integer',
'level_id' => 'integer',
'price' => 'decimal:2',
];
/**
* 所属商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
/**
* 所属客户等级
*/
public function level(): BelongsTo
{
return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id');
}
/**
* 按商品 + 等级筛选价格
*/
public function scopeForProductLevel($query, int $productId, int $levelId)
{
return $query->where('product_id', $productId)->where('level_id', $levelId);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 采购金额分摊模型(实际采购金额按订货比例分摊到门店/单品,尾差修正保证金额守恒)
*/
class PurchaseAllocationModel extends Model
{
protected $table = 'purchase_allocation';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_item_id',
'order_item_id',
'store_id',
'product_id',
'quantity',
'weight',
'amount',
];
protected $casts = [
'purchase_item_id' => 'integer',
'order_item_id' => 'integer',
'store_id' => 'integer',
'product_id' => 'integer',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'amount' => 'decimal:2',
];
/**
* 来源采购明细
*/
public function purchaseItem(): BelongsTo
{
return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id');
}
/**
* 溯源订货明细
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
}
/**
* 分摊门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 分摊商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 采购单明细模型(按商品+供应商聚合,快照品名/规格;实际金额录入后用于分摊)
*/
class PurchaseOrderItemModel extends Model
{
use HasFactory;
/** 未发送供应商 */
public const NOT_SENT = 0;
/** 已发送供应商 */
public const SENT = 1;
protected $table = 'purchase_order_item';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_id',
'product_id',
'supplier_id',
'product_name',
'product_spec',
'price',
'quantity',
'weight',
'amount',
'sort',
'is_sent',
'sent_at',
'supplier_confirmed_at',
'remark',
];
protected $casts = [
'purchase_id' => 'integer',
'product_id' => 'integer',
'supplier_id' => 'integer',
'price' => 'decimal:2',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'amount' => 'decimal:2',
'sort' => 'integer',
'is_sent' => 'integer',
'sent_at' => 'datetime',
'supplier_confirmed_at' => 'datetime',
];
/**
* 所属采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 采购商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
/**
* 供货供应商
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
}
/**
* 金额分摊记录(按订货比例摊到门店/单品)
*/
public function allocations(): HasMany
{
return $this->hasMany(PurchaseAllocationModel::class, 'purchase_item_id', 'id');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 采购单模型(按门店订单汇总生成,按商品+供应商聚合)
*/
class PurchaseOrderModel extends Model
{
use HasFactory;
/** 状态:待发送 */
public const STATUS_PENDING = 0;
/** 状态:部分发送 */
public const STATUS_PART_SENT = 1;
/** 状态:全部发送 */
public const STATUS_ALL_SENT = 2;
/** 状态:已完成 */
public const STATUS_COMPLETED = 3;
protected $table = 'purchase_order';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_no',
'purchase_date',
'status',
'total_quantity',
'total_weight',
'estimate_amount',
'actual_amount',
'operator_id',
'remark',
];
protected $casts = [
'purchase_date' => 'date:Y-m-d',
'status' => 'integer',
'total_quantity' => 'decimal:2',
'total_weight' => 'decimal:3',
'estimate_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'operator_id' => 'integer',
];
/**
* 制单人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 采购明细
*/
public function items(): HasMany
{
return $this->hasMany(PurchaseOrderItemModel::class, 'purchase_id', 'id')->orderBy('sort');
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 对账明细模型(订货量/称重/数量/金额可修改,diff = publish actual
*/
class ReconciliationItemModel extends Model
{
/** 未对账 */
public const NOT_RECONCILED = 0;
/** 已对账 */
public const RECONCILED = 1;
protected $table = 'reconciliation_item';
protected $primaryKey = 'id';
protected $fillable = [
'recon_id',
'store_id',
'purchase_item_id',
'order_item_id',
'product_id',
'product_name',
'quantity',
'weight',
'publish_amount',
'actual_amount',
'diff_amount',
'is_reconciled',
'store_remark',
'sort',
];
protected $casts = [
'recon_id' => 'integer',
'store_id' => 'integer',
'purchase_item_id' => 'integer',
'order_item_id' => 'integer',
'product_id' => 'integer',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'publish_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'is_reconciled' => 'integer',
'sort' => 'integer',
];
/**
* 所属对账单
*/
public function recon(): BelongsTo
{
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
}
/**
* 所属门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 对账商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
/**
* 来源采购明细
*/
public function purchaseItem(): BelongsTo
{
return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id');
}
/**
* 溯源订货明细
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 财务对账模型(公布金额 vs 实际金额 vs 差额,按品类/供应商筛选)
*/
class ReconciliationModel extends Model
{
/** 状态:草稿 */
public const STATUS_DRAFT = 0;
/** 状态:对账中 */
public const STATUS_WORKING = 1;
/** 状态:已结算 */
public const STATUS_SETTLED = 2;
protected $table = 'reconciliation';
protected $primaryKey = 'id';
protected $fillable = [
'recon_no',
'title',
'period_start',
'period_end',
'category_id',
'supplier_id',
'publish_amount',
'actual_amount',
'diff_amount',
'status',
'operator_id',
'remark',
];
protected $casts = [
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'category_id' => 'integer',
'supplier_id' => 'integer',
'publish_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'status' => 'integer',
'operator_id' => 'integer',
];
/**
* 制单人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 对账明细
*/
public function items(): HasMany
{
return $this->hasMany(ReconciliationItemModel::class, 'recon_id', 'id')->orderBy('sort');
}
/**
* 结算表
*/
public function settlements(): HasMany
{
return $this->hasMany(SettlementModel::class, 'recon_id', 'id');
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 结算表模型(对账结算按门店聚合生成,可导出存档)
*/
class SettlementModel extends Model
{
/** 状态:待结算 */
public const STATUS_PENDING = 0;
/** 状态:已结算 */
public const STATUS_SETTLED = 1;
protected $table = 'settlement';
protected $primaryKey = 'id';
protected $fillable = [
'settlement_no',
'recon_id',
'store_id',
'period_start',
'period_end',
'total_amount',
'actual_amount',
'diff_amount',
'status',
'file_path',
'operator_id',
'settled_at',
'remark',
];
protected $casts = [
'recon_id' => 'integer',
'store_id' => 'integer',
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'total_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'diff_amount' => 'decimal:2',
'status' => 'integer',
'operator_id' => 'integer',
'settled_at' => 'datetime',
];
/**
* 来源对账单
*/
public function recon(): BelongsTo
{
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
}
/**
* 结算门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 制单人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 门店对账单明细模型(快照商品名/单价/数量/金额)
*/
class StatementItemModel extends Model
{
/** 未对账 */
public const NOT_RECONCILED = 0;
/** 已对账 */
public const RECONCILED = 1;
protected $table = 'statement_item';
protected $primaryKey = 'id';
protected $fillable = [
'statement_id',
'order_id',
'order_item_id',
'product_id',
'product_name',
'price',
'quantity',
'weight',
'amount',
'is_reconciled',
'store_remark',
];
protected $casts = [
'statement_id' => 'integer',
'order_id' => 'integer',
'order_item_id' => 'integer',
'product_id' => 'integer',
'price' => 'decimal:2',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'amount' => 'decimal:2',
'is_reconciled' => 'integer',
];
/**
* 所属对账单
*/
public function statement(): BelongsTo
{
return $this->belongsTo(StatementModel::class, 'statement_id', 'id');
}
/**
* 来源订单
*/
public function order(): BelongsTo
{
return $this->belongsTo(StoreOrderModel::class, 'order_id', 'id');
}
/**
* 来源订单明细
*/
public function orderItem(): BelongsTo
{
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
}
/**
* 对账商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 门店对账单模型(门店自助生成,快照回款周期:settlement_date = period_end + payment_cycle_days
*/
class StatementModel extends Model
{
/** 状态:待对账 */
public const STATUS_PENDING = 0;
/** 状态:已对账 */
public const STATUS_RECONCILED = 1;
/** 状态:已结算 */
public const STATUS_SETTLED = 2;
protected $table = 'statement';
protected $primaryKey = 'id';
protected $fillable = [
'statement_no',
'store_id',
'period_start',
'period_end',
'total_amount',
'payment_cycle_days',
'settlement_date',
'status',
'reconciled_at',
'settled_at',
'remark',
];
protected $casts = [
'store_id' => 'integer',
'period_start' => 'date:Y-m-d',
'period_end' => 'date:Y-m-d',
'total_amount' => 'decimal:2',
'payment_cycle_days' => 'integer',
'settlement_date' => 'date:Y-m-d',
'status' => 'integer',
'reconciled_at' => 'datetime',
'settled_at' => 'datetime',
];
/**
* 所属门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 对账单明细
*/
public function items(): HasMany
{
return $this->hasMany(StatementItemModel::class, 'statement_id', 'id');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 门店模型(小程序下单主体)
*/
class StoreModel extends Model
{
use SoftDeletes, HasFactory;
/** 状态:停用 */
public const STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
protected $table = 'store';
protected $primaryKey = 'id';
protected $fillable = [
'name',
'code',
'level_id',
'contact',
'phone',
'address',
'payment_cycle_days',
'status',
'remark',
];
protected $casts = [
'level_id' => 'integer',
'payment_cycle_days' => 'integer',
'status' => 'integer',
];
/**
* 所属客户等级(决定商品展示价格)
*/
public function level(): BelongsTo
{
return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id');
}
/**
* 门店订单
*/
public function orders(): HasMany
{
return $this->hasMany(StoreOrderModel::class, 'store_id', 'id');
}
/**
* 绑定本门店的小程序用户
*/
public function users(): HasMany
{
return $this->hasMany(UserModel::class, 'store_id', 'id');
}
/**
* 门店对账单
*/
public function statements(): HasMany
{
return $this->hasMany(StatementModel::class, 'store_id', 'id');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 门店订单明细模型(快照下单时的商品名/规格/等级价,历史单据不受调价影响)
*/
class StoreOrderItemModel extends Model
{
use HasFactory;
protected $table = 'store_order_item';
protected $primaryKey = 'id';
protected $fillable = [
'order_id',
'store_id',
'product_id',
'product_name',
'product_spec',
'price',
'quantity',
'weight',
'amount',
'remark',
];
protected $casts = [
'order_id' => 'integer',
'store_id' => 'integer',
'product_id' => 'integer',
'price' => 'decimal:2',
'quantity' => 'decimal:2',
'weight' => 'decimal:3',
'amount' => 'decimal:2',
];
/**
* 所属订单
*/
public function order(): BelongsTo
{
return $this->belongsTo(StoreOrderModel::class, 'order_id', 'id');
}
/**
* 下单门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 下单商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 门店订单模型(小程序下单,快照等级价)
*/
class StoreOrderModel extends Model
{
use HasFactory;
/** 状态:待汇总(可被采购单生成归集、可取消) */
public const STATUS_PENDING = 0;
/** 状态:已汇总(已生成采购单) */
public const STATUS_SUMMARIZED = 1;
/** 状态:配送中 */
public const STATUS_DELIVERING = 2;
/** 状态:已完成 */
public const STATUS_COMPLETED = 3;
/** 状态:已取消 */
public const STATUS_CANCELLED = 9;
protected $table = 'store_order';
protected $primaryKey = 'id';
protected $fillable = [
'order_no',
'store_id',
'order_date',
'total_quantity',
'total_weight',
'total_amount',
'status',
'remark',
];
protected $casts = [
'store_id' => 'integer',
'order_date' => 'date:Y-m-d',
'total_quantity' => 'decimal:2',
'total_weight' => 'decimal:3',
'total_amount' => 'decimal:2',
'status' => 'integer',
];
/**
* 下单门店
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 订单明细
*/
public function items(): HasMany
{
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 供应商模型(采购单接收方)
*/
class SupplierModel extends Model
{
use SoftDeletes, HasFactory;
/** 状态:停用 */
public const STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
protected $table = 'supplier';
protected $primaryKey = 'id';
protected $fillable = [
'name',
'contact',
'phone',
'address',
'main_products',
'status',
'remark',
];
protected $casts = [
'status' => 'integer',
];
/**
* 供应商供货的商品
*/
public function products(): HasMany
{
return $this->hasMany(ProductModel::class, 'supplier_id', 'id');
}
/**
* 供应商的采购明细行
*/
public function purchaseItems(): HasMany
{
return $this->hasMany(PurchaseOrderItemModel::class, 'supplier_id', 'id');
}
/**
* 绑定本供应商的小程序用户
*/
public function users(): HasMany
{
return $this->hasMany(UserModel::class, 'supplier_id', 'id');
}
}
+66 -2
View File
@@ -2,17 +2,31 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* APP 用户模型
* APP 用户模型(小程序端:门店 / 供应商用户)
*/
class UserModel extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/** 用户类型:待绑定(手机号未匹配到门店/供应商,需后台人工绑定) */
public const TYPE_PENDING = 0;
/** 用户类型:门店 */
public const TYPE_STORE = 1;
/** 用户类型:供应商 */
public const TYPE_SUPPLIER = 2;
/** 状态:停用 */
public const STATUS_DISABLED = 0;
/** 状态:正常 */
public const STATUS_NORMAL = 1;
protected $table = 'user';
protected $primaryKey = 'id';
@@ -23,10 +37,60 @@ class UserModel extends Authenticatable
];
protected $fillable = [
'mobile',
'username',
'email',
'password',
'nickname',
'openid',
'unionid',
'phone',
'avatar',
'type',
'store_id',
'supplier_id',
'status',
'last_login_at',
];
protected $casts = [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'type' => 'integer',
'store_id' => 'integer',
'supplier_id' => 'integer',
'status' => 'integer',
];
/**
* 关联门店(type=1 时有效)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
}
/**
* 关联供应商(type=2 时有效)
*/
public function supplier(): BelongsTo
{
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
}
/**
* 用户通知
*/
public function notices(): HasMany
{
return $this->hasMany(NoticeModel::class, 'user_id', 'id');
}
/**
* 是否已绑定业务主体(门店或供应商)
*/
public function isBound(): bool
{
return $this->type === self::TYPE_STORE && $this->store_id > 0
|| $this->type === self::TYPE_SUPPLIER && $this->supplier_id > 0;
}
}
+4
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Services\WechatService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
use Illuminate\Support\ServiceProvider;
@@ -17,6 +18,9 @@ class AppServiceProvider extends ServiceProvider
{
$this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class);
// 单例:测试通过 setHttpClient() 注入 Mock 后,控制器解析到同一实例
$this->app->singleton(WechatService::class);
}
/**
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* 单号生成服务
*
* 规则:前缀 + yyyyMMdd + 4 位序列,如 PO202607230001
* 按「前缀+当日」已生成的最大序列自增。
*
* 并发安全提示:本服务取当日最大单号 +1,依赖各单号字段的唯一索引兜底,
* 高并发生成场景(如采购单汇总)须在事务内配合行锁调用(见 PurchaseGenerateService)。
*/
class BillNumberService
{
/**
* 前缀 → [表名, 单号字段] 映射
*
* @var array<string, array{0: string, 1: string}>
*/
private const NUMBER_SOURCES = [
'PO' => ['purchase_order', 'purchase_no'],
'SO' => ['store_order', 'order_no'],
'RC' => ['reconciliation', 'recon_no'],
'ST' => ['statement', 'statement_no'],
'JS' => ['settlement', 'settlement_no'],
];
/**
* 生成业务单号
*
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算
* @return string 如 PO202607230001
*/
public function make(string $prefix): string
{
$prefix = strtoupper($prefix);
$source = self::NUMBER_SOURCES[$prefix]
?? throw new InvalidArgumentException('不支持的单号前缀:' . $prefix);
[$table, $column] = $source;
$datePrefix = $prefix . now()->format('Ymd');
$maxNo = DB::table($table)
->where($column, 'like', $datePrefix . '%')
->lockForUpdate()
->max($column);
$sequence = 1;
if (is_string($maxNo) && $maxNo !== '') {
$sequence = ((int) substr($maxNo, strlen($datePrefix))) + 1;
}
return $datePrefix . str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Exports\SettlementExport;
use App\Exports\StatementExport;
use App\Models\PurchaseOrderModel;
use App\Models\SettlementModel;
use App\Models\StatementModel;
use Barryvdh\DomPDF\Facade\Pdf;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\Response;
/**
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
*
* - Excel 分支:Excel::download(new XxxExport(...))
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
*/
class ExportService
{
/** 导出格式:Excel */
public const FORMAT_XLSX = 'xlsx';
/** 导出格式:PDF */
public const FORMAT_PDF = 'pdf';
/**
* 导出下载
*
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
* @return Response 文件流响应(blob
*/
public function download(
string $business,
mixed $subject,
string $format = self::FORMAT_XLSX,
?string $type = null,
): Response {
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf');
}
return match ($business) {
'purchase' => $this->exportPurchase($subject, $format, $type),
'statement' => $this->exportStatement($subject, $format),
'settlement' => $this->exportSettlement($subject, $format),
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
};
}
/**
* C2/C3 采购单导出
*/
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
{
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $purchase->purchase_no . '_采购单.' . $extension;
$export = new PurchaseOrderExport($purchase, $type);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.purchase', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* 门店对账单导出
*/
private function exportStatement(StatementModel $statement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $statement->statement_no . '_对账单.' . $extension;
$export = new StatementExport($statement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.statement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* D10 结算表导出
*/
private function exportSettlement(SettlementModel $settlement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $settlement->settlement_no . '_结算表.' . $extension;
$export = new SettlementExport($settlement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.settlement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseAllocationModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
/**
* D3 采购金额分摊
*
* 流程(事务内):
* 1. 采购单须已录入实际金额(存在 amount>0 的明细),否则拒绝
* 2. 每个采购明细溯源「采购日当天、已汇总订单」中该商品的订货明细
* 3. 按订货数量比例分摊实际金额/数量/重量:bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)
* 尾差修正——最后一行承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒)
* 4. 重复分摊先删旧记录再重建(幂等)
*/
class PurchaseAllocateService
{
/**
* @return int 生成的分摊记录数
*/
public function allocate(PurchaseOrderModel $purchase): int
{
return DB::transaction(function () use ($purchase) {
$items = PurchaseOrderItemModel::query()
->where('purchase_id', $purchase->id)
->lockForUpdate()
->get();
// 1. 须已录入实际金额
if (! $items->contains(static fn ($item) => (float) $item->amount > 0)) {
throw new RepositoryException('采购单尚未录入实际金额,无法分摊');
}
// 2. 溯源采购日当天「已汇总」订单的订货明细,按商品分组
$orderItems = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->whereDate('store_order.order_date', $purchase->purchase_date)
->where('store_order.status', StoreOrderModel::STATUS_SUMMARIZED)
->select('store_order_item.*')
->get()
->groupBy('product_id');
// 4. 幂等:删除旧分摊记录
PurchaseAllocationModel::query()
->whereIn('purchase_item_id', $items->pluck('id'))
->delete();
$count = 0;
$now = now();
foreach ($items as $item) {
$sources = $orderItems->get((int) $item->product_id);
if ($sources === null || $sources->isEmpty()) {
continue;
}
$totalQty = $sources->reduce(
static fn (string $carry, $orderItem): string => bcadd($carry, (string) $orderItem->quantity, 2),
'0'
);
if ((float) $totalQty <= 0) {
continue;
}
$sourceValues = $sources->values();
$lastIndex = $sourceValues->count() - 1;
$allocatedAmount = '0';
$allocatedQuantity = '0';
$allocatedWeight = '0';
$records = [];
foreach ($sourceValues as $index => $orderItem) {
if ($index === $lastIndex) {
// 3. 尾差修正:最后一行 = 总额 − 已分摊,保证守恒
$amount = bcsub((string) $item->amount, $allocatedAmount, 2);
$quantity = bcsub((string) $item->quantity, $allocatedQuantity, 2);
$weight = bcsub((string) $item->weight, $allocatedWeight, 3);
} else {
$ratio = bcdiv((string) $orderItem->quantity, $totalQty, 6);
$amount = bcmul((string) $item->amount, $ratio, 2);
$quantity = bcmul((string) $item->quantity, $ratio, 2);
$weight = bcmul((string) $item->weight, $ratio, 3);
$allocatedAmount = bcadd($allocatedAmount, $amount, 2);
$allocatedQuantity = bcadd($allocatedQuantity, $quantity, 2);
$allocatedWeight = bcadd($allocatedWeight, $weight, 3);
}
$records[] = [
'purchase_item_id' => $item->id,
'order_item_id' => $orderItem->id,
'store_id' => $orderItem->store_id,
'product_id' => $item->product_id,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'created_at' => $now,
'updated_at' => $now,
];
}
PurchaseAllocationModel::insert($records);
$count += count($records);
}
return $count;
});
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
/**
* C1 订单汇总生成采购单
*
* 流程(事务内):
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
* 6. 源订单批量回写 status = 已汇总
*/
class PurchaseGenerateService
{
public function __construct(private readonly BillNumberService $billNumberService)
{
}
/**
* @param string $date 订货/采购日期(Y-m-d
* @param int $operatorId 制单人(后台系统用户ID)
*/
public function generate(string $date, int $operatorId): PurchaseOrderModel
{
return DB::transaction(function () use ($date, $operatorId) {
// 1. 行锁当日待汇总订单(并发防护)
$orders = StoreOrderModel::query()
->whereDate('order_date', $date)
->where('status', StoreOrderModel::STATUS_PENDING)
->lockForUpdate()
->get();
if ($orders->isEmpty()) {
throw new RepositoryException('当日无待汇总订单');
}
// 2. 展开明细按商品聚合
$aggregated = [];
$orderIds = [];
foreach ($orders as $order) {
$orderIds[] = $order->id;
foreach ($order->items as $item) {
$productId = (int) $item->product_id;
if (! isset($aggregated[$productId])) {
$aggregated[$productId] = [
'product_id' => $productId,
'product_name' => $item->product_name,
'product_spec' => $item->product_spec,
'quantity' => '0',
];
}
$aggregated[$productId]['quantity'] = bcadd(
$aggregated[$productId]['quantity'],
(string) $item->quantity,
2
);
}
}
if ($aggregated === []) {
throw new RepositoryException('当日待汇总订单均无明细,无法生成采购单');
}
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', array_keys($aggregated))
->get()
->keyBy('id');
// 3. 估算单价 = 最低等级价
$minPrices = ProductPriceModel::query()
->whereIn('product_id', array_keys($aggregated))
->groupBy('product_id')
->selectRaw('product_id, MIN(price) as min_price')
->pluck('min_price', 'product_id');
// 组装明细行并按「分类 sort → 商品 sort」排序
$rows = [];
foreach ($aggregated as $productId => $item) {
$product = $products->get($productId);
$price = (string) ($minPrices[$productId] ?? '0');
$rows[] = [
'product_id' => $productId,
'supplier_id' => (int) ($product->supplier_id ?? 0),
'product_name' => $item['product_name'],
'product_spec' => $item['product_spec'],
'price' => $price,
'quantity' => $item['quantity'],
'amount' => bcmul($item['quantity'], $price, 2),
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
];
}
usort($rows, static fn (array $a, array $b): int =>
[$a['category_sort'], $a['product_sort'], $a['product_id']]
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
$estimateAmount = array_reduce(
$rows,
static fn (string $carry, array $row): string => bcadd($carry, $row['amount'], 2),
'0'
);
$totalQuantity = array_reduce(
$rows,
static fn (string $carry, array $row): string => bcadd($carry, $row['quantity'], 2),
'0'
);
// 4. 采购单头
$purchase = PurchaseOrderModel::create([
'purchase_no' => $this->billNumberService->make('PO'),
'purchase_date' => $date,
'status' => PurchaseOrderModel::STATUS_PENDING,
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'estimate_amount' => $estimateAmount,
'actual_amount' => 0,
'operator_id' => $operatorId,
]);
// 5. 采购明细(sort 行号)
$sort = 1;
foreach ($rows as $row) {
PurchaseOrderItemModel::create([
'purchase_id' => $purchase->id,
'product_id' => $row['product_id'],
'supplier_id' => $row['supplier_id'],
'product_name' => $row['product_name'],
'product_spec' => $row['product_spec'],
'price' => $row['price'],
'quantity' => $row['quantity'],
'weight' => 0,
'amount' => $row['amount'],
'sort' => $sort++,
'is_sent' => PurchaseOrderItemModel::NOT_SENT,
]);
}
// 6. 源订单回写「已汇总」
StoreOrderModel::whereIn('id', $orderIds)
->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
return $purchase;
});
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseAllocationModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use App\Models\StoreOrderItemModel;
use Illuminate\Support\Facades\DB;
/**
* 对账明细构建(D1 品类 / D2 供应商筛选)
*
* 流程(事务内,可重复 build:先清后建):
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取 purchase_order_item
* 2. 校验命中的采购明细均已完成 D3 分摊(分摊是门店/单品粒度的对账数据源)
* 3. 每条分摊记录 → 一条对账明细:
* publish_amount = 订货金额(溯源 order_item.amount
* actual_amount = 分摊金额
* diff = publish actual,冗余 product_name / store_id
* 4. 汇总写回头的 publish/actual/diff_amountstatus → 对账中
*/
class ReconciliationBuildService
{
/**
* @return int 生成的对账明细数
*/
public function build(ReconciliationModel $recon): int
{
return DB::transaction(function () use ($recon) {
// 1. 按周期 + 品类 + 供应商拉取采购明细
$itemQuery = PurchaseOrderItemModel::query()
->join('purchase_order', 'purchase_order.id', '=', 'purchase_order_item.purchase_id')
->whereDate('purchase_order.purchase_date', '>=', $recon->period_start)
->whereDate('purchase_order.purchase_date', '<=', $recon->period_end)
->select('purchase_order_item.*');
if ((int) $recon->supplier_id > 0) {
$itemQuery->where('purchase_order_item.supplier_id', $recon->supplier_id);
}
if ((int) $recon->category_id > 0) {
$productIds = ProductModel::withTrashed()
->whereIn('category_id', $this->descendantCategoryIds((int) $recon->category_id))
->pluck('id');
$itemQuery->whereIn('purchase_order_item.product_id', $productIds);
}
$purchaseItems = $itemQuery->get();
if ($purchaseItems->isEmpty()) {
throw new RepositoryException('周期内无符合筛选条件的采购数据,无法生成对账明细');
}
// 2. 分摊记录(未完成分摊的采购单拒绝,保证对账数据到门店/单品粒度)
$allocations = PurchaseAllocationModel::query()
->whereIn('purchase_item_id', $purchaseItems->pluck('id'))
->get()
->groupBy('purchase_item_id');
$missing = $purchaseItems->filter(fn ($item) => ! $allocations->has($item->id));
if ($missing->isNotEmpty()) {
$purchaseNos = PurchaseOrderModel::query()
->whereIn('id', $missing->pluck('purchase_id')->unique())
->pluck('purchase_no')
->implode('、');
throw new RepositoryException('采购单 ' . $purchaseNos . ' 尚未完成金额分摊,请先执行分摊再生成对账明细');
}
// 3. 溯源订货金额(公布金额)
$orderAmounts = StoreOrderItemModel::query()
->whereIn('id', $allocations->flatten()->pluck('order_item_id')->unique())
->pluck('amount', 'id');
// 4. 先清后建(幂等)
ReconciliationItemModel::where('recon_id', $recon->id)->delete();
$publishTotal = '0';
$actualTotal = '0';
$rows = [];
$sort = 1;
$now = now();
foreach ($allocations as $purchaseItemId => $group) {
$purchaseItem = $purchaseItems->firstWhere('id', $purchaseItemId);
foreach ($group as $allocation) {
$publish = (string) ($orderAmounts[$allocation->order_item_id] ?? '0');
$actual = (string) $allocation->amount;
$publishTotal = bcadd($publishTotal, $publish, 2);
$actualTotal = bcadd($actualTotal, $actual, 2);
$rows[] = [
'recon_id' => $recon->id,
'store_id' => $allocation->store_id,
'purchase_item_id' => $allocation->purchase_item_id,
'order_item_id' => $allocation->order_item_id,
'product_id' => $allocation->product_id,
'product_name' => $purchaseItem->product_name,
'quantity' => $allocation->quantity,
'weight' => $allocation->weight,
'publish_amount' => $publish,
'actual_amount' => $actual,
'diff_amount' => bcsub($publish, $actual, 2),
'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED,
'store_remark' => '',
'sort' => $sort++,
'created_at' => $now,
'updated_at' => $now,
];
}
}
ReconciliationItemModel::insert($rows);
// 5. 汇总写回头 + 状态流转
$recon->publish_amount = $publishTotal;
$recon->actual_amount = $actualTotal;
$recon->diff_amount = bcsub($publishTotal, $actualTotal, 2);
$recon->status = ReconciliationModel::STATUS_WORKING;
$recon->save();
return count($rows);
});
}
/**
* 分类自身 + 全部子孙分类ID(多级分类下按顶级分类筛选)
*
* @return array<int, int>
*/
private function descendantCategoryIds(int $categoryId): array
{
$parentMap = ProductCategoryModel::pluck('parent_id', 'id');
$ids = [$categoryId];
$queue = [$categoryId];
while ($queue !== []) {
$current = array_shift($queue);
foreach ($parentMap as $id => $parentId) {
if ((int) $parentId === $current && ! in_array((int) $id, $ids, true)) {
$ids[] = (int) $id;
$queue[] = (int) $id;
}
}
}
return $ids;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\StatementItemModel;
use App\Models\StatementModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 门店对账单生成(小程序端自助生成)
*
* 流程(事务内):
* 1. 拉取门店周期内的订单明细(排除已取消订单,按 order_item 去重防止重复入账)
* 2. 快照当前 payment_cycle_dayssettlement_date = period_end + cycle 天
* 3. 明细快照商品名/单价/数量/重量/金额,statement_no = ST…
*/
class StatementGenerateService
{
public function __construct(private readonly BillNumberService $billNumberService)
{
}
/**
* @param StoreModel $store 门店(回款周期从此快照)
* @param string $periodStart 周期开始(Y-m-d
* @param string $periodEnd 周期结束(Y-m-d
*/
public function generate(StoreModel $store, string $periodStart, string $periodEnd): StatementModel
{
return DB::transaction(function () use ($store, $periodStart, $periodEnd) {
$orderItems = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.store_id', $store->id)
->whereDate('store_order.order_date', '>=', $periodStart)
->whereDate('store_order.order_date', '<=', $periodEnd)
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
->select('store_order_item.*')
->get();
if ($orderItems->isEmpty()) {
throw new RepositoryException('周期内本店无订单数据,无法生成对账单');
}
// 防重复入账:剔除已计入过对账单的订单明细
$usedItemIds = StatementItemModel::query()
->whereIn('statement_id', StatementModel::where('store_id', $store->id)->pluck('id'))
->pluck('order_item_id');
$orderItems = $orderItems->reject(fn ($item) => $usedItemIds->contains($item->id));
if ($orderItems->isEmpty()) {
throw new RepositoryException('周期内的订单明细均已生成过对账单');
}
// 快照回款周期 → 应结算日期
$cycleDays = (int) $store->payment_cycle_days;
$totalAmount = $orderItems->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
'0'
);
$statement = StatementModel::create([
'statement_no' => $this->billNumberService->make('ST'),
'store_id' => $store->id,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'total_amount' => $totalAmount,
'payment_cycle_days' => $cycleDays,
'settlement_date' => Carbon::parse($periodEnd)->addDays($cycleDays)->toDateString(),
'status' => StatementModel::STATUS_PENDING,
]);
$rows = [];
$now = now();
foreach ($orderItems as $item) {
$rows[] = [
'statement_id' => $statement->id,
'order_id' => $item->order_id,
'order_item_id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $item->product_name,
'price' => $item->price,
'quantity' => $item->quantity,
'weight' => $item->weight,
'amount' => $item->amount,
'is_reconciled' => StatementItemModel::NOT_RECONCILED,
'store_remark' => '',
'created_at' => $now,
'updated_at' => $now,
];
}
StatementItemModel::insert($rows);
return $statement;
});
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use EasyWeChat\Kernel\Exceptions\HttpException;
use EasyWeChat\MiniApp\Application;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* 微信小程序服务(基于 EasyWeChat 6.x
*
* 封装 code2Session / 手机号解密;配置读取 config('services.wechat.mini')
* envWECHAT_MINI_APPID / WECHAT_MINI_SECRET,需业务方提供)。
*
* 测试策略:通过 setHttpClient() 注入 Symfony MockHttpClient 拦截微信 HTTP 调用。
*/
class WechatService
{
private ?Application $app = null;
private ?HttpClientInterface $httpClient = null;
/**
* 注入自定义 HttpClient(测试注入 MockHttpClient;注入后强制重建 Application
*/
public function setHttpClient(HttpClientInterface $httpClient): void
{
$this->httpClient = $httpClient;
$this->app = null;
}
/**
* code2Session:小程序 wx.login 的 code 换取 openid / session_key
*
* @param string $code wx.login 返回的临时登录凭证
* @return array{openid: string, session_key: string, unionid?: string}
*/
public function code2Session(string $code): array
{
try {
/** @var array{openid: string, session_key: string, unionid?: string} $session */
$session = $this->app()->getUtils()->codeToSession($code);
} catch (HttpException|TransportExceptionInterface $e) {
throw new RepositoryException('微信登录失败:' . $e->getMessage());
}
return $session;
}
/**
* 获取手机号:wx.getPhoneNumber 的 phoneCode 换取手机号
*
* @param string $phoneCode 手机号授权事件返回的动态令牌
* @return string 用户手机号
*/
public function getPhone(string $phoneCode): string
{
try {
$result = $this->app()->getUtils()->getPhoneNumber($phoneCode);
} catch (HttpException|TransportExceptionInterface $e) {
throw new RepositoryException('获取手机号失败:' . $e->getMessage());
}
$phone = (string) ($result['phone_info']['phoneNumber']
?? $result['phone_info']['purePhoneNumber']
?? '');
if ($phone === '') {
throw new RepositoryException('获取手机号失败:微信未返回有效手机号');
}
return $phone;
}
/**
* EasyWeChat 小程序应用实例(懒构建单例)
*/
protected function app(): Application
{
if ($this->app === null) {
$config = (array) config('services.wechat.mini', []);
if (empty($config['appid']) || empty($config['secret'])) {
throw new RepositoryException('微信小程序尚未配置(WECHAT_MINI_APPID / WECHAT_MINI_SECRET');
}
$this->app = new Application([
'app_id' => (string) $config['appid'],
'secret' => (string) $config['secret'],
]);
if ($this->httpClient !== null) {
$this->app->setHttpClient($this->httpClient);
}
}
return $this->app;
}
}
+3 -1
View File
@@ -16,7 +16,8 @@
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"maatwebsite/excel": "^3.1",
"predis/predis": "2.0"
"predis/predis": "2.0",
"w7corp/easywechat": "^6.19"
},
"require-dev": {
"laravel/boost": "^2.0",
@@ -30,6 +31,7 @@
"psr-4": {
"App\\": "app/",
"Modules\\": "modules",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
"files": [
+10
View File
@@ -42,4 +42,14 @@ return [
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
'scheme' => 'https',
],
/*
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
*/
'wechat' => [
'mini' => [
'appid' => env('WECHAT_MINI_APPID', ''),
'secret' => env('WECHAT_MINI_SECRET', ''),
],
],
];
@@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\CustomerLevelModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 客户等级工厂(确定性序列数据,无需 Faker)
*
* @extends Factory<CustomerLevelModel>
*/
class CustomerLevelModelFactory extends Factory
{
protected $model = CustomerLevelModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'name' => '客户等级' . $seq,
'sort' => $seq,
'status' => CustomerLevelModel::STATUS_NORMAL,
'remark' => '',
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace Database\Factories;
use App\Models\ProductModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 商品工厂(确定性序列数据,无需 Faker)
*
* @extends Factory<ProductModel>
*/
class ProductModelFactory extends Factory
{
protected $model = ProductModel::class;
private const NAMES = ['大白菜', '土豆', '西红柿', '黄瓜', '苹果', '香蕉'];
private const SPECS = ['500g/袋', '10斤/箱', '散装', '25斤/袋'];
private const GRADES = ['特级', '一级', '二级'];
private const UNITS = ['斤', '箱', '袋'];
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'category_id' => 0,
'supplier_id' => 0,
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
'spec' => self::SPECS[$seq % count(self::SPECS)],
'grade' => self::GRADES[$seq % count(self::GRADES)],
'unit' => self::UNITS[$seq % count(self::UNITS)],
'image' => '',
'sort' => $seq,
'status' => ProductModel::STATUS_ON,
'remark' => '',
];
}
/**
* 下架商品
*/
public function off(): static
{
return $this->state(fn () => ['status' => ProductModel::STATUS_OFF]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\ProductPriceModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 商品等级价格工厂(product_id / level_id 需调用方指定;无需 Faker)
*
* @extends Factory<ProductPriceModel>
*/
class ProductPriceModelFactory extends Factory
{
protected $model = ProductPriceModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'product_id' => 0,
'level_id' => 0,
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
];
}
}
@@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Models\PurchaseOrderModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 采购单工厂(无需 Faker
*
* @extends Factory<PurchaseOrderModel>
*/
class PurchaseOrderModelFactory extends Factory
{
protected $model = PurchaseOrderModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'purchase_no' => 'PO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
'purchase_date' => now()->toDateString(),
'status' => PurchaseOrderModel::STATUS_PENDING,
'total_quantity' => 0,
'total_weight' => 0,
'estimate_amount' => 0,
'actual_amount' => 0,
'operator_id' => 0,
'remark' => '',
];
}
/**
* 指定采购日期
*/
public function onDate(string $date): static
{
return $this->state(fn () => ['purchase_date' => $date]);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Models\StoreModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 门店工厂(确定性序列数据,无需 Faker)
*
* @extends Factory<StoreModel>
*/
class StoreModelFactory extends Factory
{
protected $model = StoreModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'name' => '测试门店' . $seq,
'code' => 'S' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT),
'level_id' => 0,
'contact' => '联系人' . $seq,
'phone' => '138' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
'address' => '测试地址' . $seq . '号',
'payment_cycle_days' => $seq % 8,
'status' => StoreModel::STATUS_NORMAL,
'remark' => '',
];
}
/**
* 停用门店
*/
public function disabled(): static
{
return $this->state(fn () => ['status' => StoreModel::STATUS_DISABLED]);
}
/**
* 指定回款周期(天)
*/
public function paymentCycle(int $days): static
{
return $this->state(fn () => ['payment_cycle_days' => $days]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Database\Factories;
use App\Models\StoreOrderItemModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 门店订单明细工厂(order_id / store_id / product_id 需调用方指定;
* amount 未显式指定时按 price × quantity 自动计算;无需 Faker
*
* @extends Factory<StoreOrderItemModel>
*/
class StoreOrderItemModelFactory extends Factory
{
protected $model = StoreOrderItemModel::class;
public function definition(): array
{
return [
'order_id' => 0,
'store_id' => 0,
'product_id' => 0,
'product_name' => '测试商品',
'product_spec' => '500g/袋',
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
'weight' => 0,
'amount' => 0,
'remark' => '',
];
}
public function configure(): static
{
return $this->afterMaking(function (StoreOrderItemModel $item): void {
if ((float) $item->amount === 0.0 && (float) $item->price > 0 && (float) $item->quantity > 0) {
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
}
});
}
}
@@ -0,0 +1,50 @@
<?php
namespace Database\Factories;
use App\Models\StoreOrderModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 门店订单工厂(store_id 需调用方指定;无需 Faker)
*
* @extends Factory<StoreOrderModel>
*/
class StoreOrderModelFactory extends Factory
{
protected $model = StoreOrderModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'order_no' => 'SO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
'store_id' => 0,
'order_date' => now()->toDateString(),
'total_quantity' => 0,
'total_weight' => 0,
'total_amount' => 0,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => '',
];
}
/**
* 指定订货日期
*/
public function onDate(string $date): static
{
return $this->state(fn () => ['order_date' => $date]);
}
/**
* 已汇总(已被采购单归集)
*/
public function summarized(): static
{
return $this->state(fn () => ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Models\SupplierModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 供应商工厂(确定性序列数据,无需 Faker)
*
* @extends Factory<SupplierModel>
*/
class SupplierModelFactory extends Factory
{
protected $model = SupplierModel::class;
private const MAIN_PRODUCTS = ['蔬菜', '水果', '蔬菜/水果', '肉禽蛋'];
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'name' => '测试供应商' . $seq,
'contact' => '联系人' . $seq,
'phone' => '139' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
'address' => '供应商地址' . $seq . '号',
'main_products' => self::MAIN_PRODUCTS[$seq % count(self::MAIN_PRODUCTS)],
'status' => SupplierModel::STATUS_NORMAL,
'remark' => '',
];
}
/**
* 停用供应商
*/
public function disabled(): static
{
return $this->state(fn () => ['status' => SupplierModel::STATUS_DISABLED]);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Database\Factories;
use App\Models\UserModel;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 小程序用户工厂(微信登录自动生成,供测试使用;无需 Faker)
*
* @extends Factory<UserModel>
*/
class UserModelFactory extends Factory
{
protected $model = UserModel::class;
private static int $sequence = 0;
public function definition(): array
{
$seq = ++self::$sequence;
return [
'username' => null,
'password' => null,
'nickname' => '微信用户' . $seq,
'email' => '',
'openid' => 'openid_' . str_pad((string) $seq, 16, '0', STR_PAD_LEFT),
'unionid' => '',
'phone' => '',
'avatar' => '',
'type' => UserModel::TYPE_PENDING,
'store_id' => 0,
'supplier_id' => 0,
'status' => UserModel::STATUS_NORMAL,
'last_login_at' => null,
];
}
/**
* 已绑定门店的门店用户
*/
public function forStore(int $storeId): static
{
return $this->state(fn () => [
'type' => UserModel::TYPE_STORE,
'store_id' => $storeId,
]);
}
/**
* 已绑定供应商的供应商用户
*/
public function forSupplier(int $supplierId): static
{
return $this->state(fn () => [
'type' => UserModel::TYPE_SUPPLIER,
'supplier_id' => $supplierId,
]);
}
/**
* 停用账号
*/
public function disabled(): static
{
return $this->state(fn () => ['status' => UserModel::STATUS_DISABLED]);
}
}
@@ -22,7 +22,7 @@ return new class extends Migration
$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->integer('status')->default(0)->comment('订单状态(0待汇总 1已汇总 2配送中 3已完成 9已取消)');
$table->string('remark', 255)->default('')->comment('订单备注');
$table->timestamps();
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
@@ -18,7 +18,7 @@ return new class extends Migration
$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->integer('status')->default(0)->comment('采购单状态(0待发送 1部分发送 2全部发送 3已完成)');
$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('预估金额(按订货汇总)');
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 待确认事项 #7(已批准):供应商小程序确认接单状态落库
*/
public function up(): void
{
if (! Schema::hasColumn('purchase_order_item', 'supplier_confirmed_at')) {
Schema::table('purchase_order_item', function (Blueprint $table) {
$table->timestamp('supplier_confirmed_at')->nullable()->after('sent_at')->comment('供应商确认接单时间(NULL未确认)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('purchase_order_item', 'supplier_confirmed_at')) {
Schema::table('purchase_order_item', function (Blueprint $table) {
$table->dropColumn('supplier_confirmed_at');
});
}
}
};
+276
View File
@@ -0,0 +1,276 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 订货采购系统业务菜单与权限点种子
*
* 约定(V2.0 计划):
* - local 一律留空,name 直接写中文(layout 在 local 为空时回退显示 name,无需 i18n)
* - 幂等:顶级菜单 key 已存在时跳过,可重复执行
* - 执行后自动将全部新增权限点授予超级管理员角色(role_id=1)
*
* 执行:php artisan db:seed --class=ProcurementSeeder
*/
class ProcurementSeeder extends Seeder
{
/**
* 菜单结构:menu 目录 → route 页面 → rule 权限点
*/
private function menus(): array
{
return [
[
'type' => 'menu',
'key' => 'procurement.product',
'name' => '商品中心',
'icon' => 'ShoppingOutlined',
'children' => [
[
'type' => 'route',
'key' => 'product.category',
'name' => '分类管理',
'path' => '/product/category',
'children' => [
['type' => 'rule', 'key' => 'product.category.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'product.category.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'product.category.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'product.category.delete', 'name' => '删除'],
],
],
[
'type' => 'route',
'key' => 'product.goods',
'name' => '商品列表',
'path' => '/product/goods',
'children' => [
['type' => 'rule', 'key' => 'product.goods.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'product.goods.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
],
],
],
],
[
'type' => 'menu',
'key' => 'procurement.customer',
'name' => '客户管理',
'icon' => 'ShopOutlined',
'children' => [
[
'type' => 'route',
'key' => 'customer.store',
'name' => '门店管理',
'path' => '/customer/store',
'children' => [
['type' => 'rule', 'key' => 'customer.store.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'customer.store.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'customer.store.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'customer.store.delete', 'name' => '删除'],
],
],
[
'type' => 'route',
'key' => 'customer.level',
'name' => '客户等级',
'path' => '/customer/level',
'children' => [
['type' => 'rule', 'key' => 'customer.level.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'customer.level.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'customer.level.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'customer.level.delete', 'name' => '删除'],
],
],
[
'type' => 'route',
'key' => 'customer.supplier',
'name' => '供应商',
'path' => '/customer/supplier',
'children' => [
['type' => 'rule', 'key' => 'customer.supplier.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'customer.supplier.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'customer.supplier.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'customer.supplier.delete', 'name' => '删除'],
],
],
[
'type' => 'route',
'key' => 'customer.miniUser',
'name' => '小程序用户',
'path' => '/customer/mini-user',
'children' => [
['type' => 'rule', 'key' => 'customer.miniUser.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'customer.miniUser.update', 'name' => '启用停用'],
['type' => 'rule', 'key' => 'customer.miniUser.bind', 'name' => '绑定主体'],
],
],
[
'type' => 'route',
'key' => 'customer.notice',
'name' => '通知管理',
'path' => '/customer/notice',
'children' => [
['type' => 'rule', 'key' => 'customer.notice.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'customer.notice.create', 'name' => '发布'],
['type' => 'rule', 'key' => 'customer.notice.delete', 'name' => '删除'],
],
],
],
],
[
'type' => 'menu',
'key' => 'procurement.order',
'name' => '订货管理',
'icon' => 'FileTextOutlined',
'children' => [
[
'type' => 'route',
'key' => 'order.store',
'name' => '门店订单',
'path' => '/order/store',
'children' => [
['type' => 'rule', 'key' => 'order.store.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'order.store.update', 'name' => '状态流转'],
],
],
],
],
[
'type' => 'menu',
'key' => 'procurement.purchase',
'name' => '采购管理',
'icon' => 'ShoppingCartOutlined',
'children' => [
[
'type' => 'route',
'key' => 'purchase.order',
'name' => '采购单',
'path' => '/purchase/order',
'children' => [
['type' => 'rule', 'key' => 'purchase.order.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'],
['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'],
['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'],
['type' => 'rule', 'key' => 'purchase.order.send', 'name' => '发送供应商'],
['type' => 'rule', 'key' => 'purchase.order.allocate', 'name' => '金额分摊'],
],
],
],
],
[
'type' => 'menu',
'key' => 'procurement.recon',
'name' => '对账管理',
'icon' => 'AccountBookOutlined',
'children' => [
[
'type' => 'route',
'key' => 'recon.list',
'name' => '财务对账',
'path' => '/recon/list',
'children' => [
['type' => 'rule', 'key' => 'recon.list.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'recon.list.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'recon.list.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'recon.list.delete', 'name' => '删除'],
['type' => 'rule', 'key' => 'recon.list.build', 'name' => '生成明细'],
['type' => 'rule', 'key' => 'recon.list.settle', 'name' => '生成结算表'],
// 对账明细操作权限点在独立控制器 recon.item 下(D4/D6/D8
['type' => 'rule', 'key' => 'recon.item.item.update', 'name' => '对账明细操作'],
],
],
[
'type' => 'route',
'key' => 'recon.statement',
'name' => '门店对账单',
'path' => '/recon/statement',
'children' => [
['type' => 'rule', 'key' => 'recon.statement.query', 'name' => '查询'],
],
],
[
'type' => 'route',
'key' => 'recon.settlement',
'name' => '结算表',
'path' => '/recon/settlement',
'children' => [
['type' => 'rule', 'key' => 'recon.settlement.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'recon.settlement.download', 'name' => '下载导出'],
],
],
],
],
];
}
public function run(): void
{
$existing = DB::table('sys_rule')
->whereIn('key', ['procurement.product', 'procurement.customer', 'procurement.order', 'procurement.purchase', 'procurement.recon'])
->exists();
if ($existing) {
$this->command?->warn('业务菜单已存在,跳过 ProcurementSeeder');
return;
}
DB::transaction(function () {
$ruleIds = [];
foreach ($this->menus() as $menu) {
$this->insertNode($menu, 0, $ruleIds);
}
// 授权给超级管理员角色(role_id=1),去重已存在的关联
$existingPairs = DB::table('sys_role_rule')
->where('role_id', 1)
->whereIn('rule_id', $ruleIds)
->pluck('rule_id')
->all();
$newPairs = array_map(
static fn (int $ruleId) => ['role_id' => 1, 'rule_id' => $ruleId],
array_values(array_diff($ruleIds, $existingPairs))
);
if ($newPairs !== []) {
DB::table('sys_role_rule')->insert($newPairs);
}
$this->command?->info('业务菜单与权限点已写入,共 ' . count($ruleIds) . ' 个权限点已授权给超级管理员');
});
}
/**
* 递归写入菜单节点,收集全部节点 ID(menu/route/rule)用于授权
*
* @param array<string, mixed> $node
* @param array<int> $ruleIds
*/
private function insertNode(array $node, int $parentId, array &$ruleIds): void
{
$now = now();
$id = DB::table('sys_rule')->insertGetId([
'parent_id' => $parentId,
'type' => $node['type'],
'key' => $node['key'],
'name' => $node['name'],
'path' => $node['path'] ?? '',
'icon' => $node['icon'] ?? '',
'order' => 0,
'local' => '',
'status' => 1,
'hidden' => 1,
'link' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$ruleIds[] = $id;
foreach ($node['children'] ?? [] as $child) {
$this->insertNode($child, $id, $ruleIds);
}
}
}
+2 -2
View File
@@ -22,8 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
-1
View File
@@ -1 +0,0 @@
import{Tr as e,t}from"./jsx-runtime-NwRKtVrk.js";import{t as n}from"./button-CM-rzMOH.js";import{t as r}from"./card-D7XvzVif.js";import{t as i}from"./result-C9ArRkbL.js";e();var a=t(),o=()=>(0,a.jsx)(r,{variant:`borderless`,children:(0,a.jsx)(i,{status:`403`,title:`403`,subTitle:`Sorry, you are not authorized to access this page.`,extra:(0,a.jsx)(n,{type:`primary`,children:`Back Home`})})});export{o as default};
-1
View File
@@ -1 +0,0 @@
import{Tr as e,t}from"./jsx-runtime-NwRKtVrk.js";import{t as n}from"./button-CM-rzMOH.js";import{t as r}from"./card-D7XvzVif.js";import{t as i}from"./result-C9ArRkbL.js";e();var a=t(),o=()=>(0,a.jsx)(r,{variant:`borderless`,children:(0,a.jsx)(i,{status:`404`,title:`404`,subTitle:`Sorry, the page you visited does not exist.`,extra:(0,a.jsx)(n,{type:`primary`,children:`Back Home`})})});export{o as default};
-1
View File
@@ -1 +0,0 @@
import{Tr as e,t}from"./jsx-runtime-NwRKtVrk.js";import{t as n}from"./button-CM-rzMOH.js";import{t as r}from"./card-D7XvzVif.js";import{t as i}from"./result-C9ArRkbL.js";e();var a=t(),o=()=>(0,a.jsx)(r,{variant:`borderless`,children:(0,a.jsx)(i,{status:`500`,title:`500`,subTitle:`Sorry, something went wrong.`,extra:(0,a.jsx)(n,{type:`primary`,children:`Back Home`})})});export{o as default};
-1
View File
@@ -1 +0,0 @@
import{Or as e,Tr as t,t as n}from"./jsx-runtime-NwRKtVrk.js";import{c as r}from"./index-B7MUL4ct.js";var i=e(t(),1),a=n(),o=({auth:e,children:t})=>{let n=r(e=>e.access);return(0,i.useMemo)(()=>e?n.includes(e):!0,[n,e])?(0,a.jsx)(a.Fragment,{children:t}):null};export{o as t};
-1
View File
@@ -1 +0,0 @@
import{Er as e,Or as t,Tn as n,Tr as r}from"./jsx-runtime-NwRKtVrk.js";var i=e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z`}}]},name:`down`,theme:`outlined`}})),a=t(r()),o=t(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(n,s({},e,{ref:t,icon:o.default}))),l=t(e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z`}}]},name:`left`,theme:`outlined`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(n,u({},e,{ref:t,icon:l.default}))),f=t(e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`0 0 1024 1024`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M912 192H328c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h584c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 284H328c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h584c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 284H328c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h584c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM104 228a56 56 0 10112 0 56 56 0 10-112 0zm0 284a56 56 0 10112 0 56 56 0 10-112 0zm0 284a56 56 0 10112 0 56 56 0 10-112 0z`}}]},name:`bars`,theme:`outlined`}}))());function p(){return p=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},p.apply(this,arguments)}var m=a.forwardRef((e,t)=>a.createElement(n,p({},e,{ref:t,icon:f.default})));export{d as n,c as r,m as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{Er as e,Or as t,Tn as n,Tr as r}from"./jsx-runtime-NwRKtVrk.js";var i=e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z`}}]},name:`check-circle`,theme:`filled`}})),a=t(r()),o=t(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(n,s({},e,{ref:t,icon:o.default}))),l=t(e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{"fill-rule":`evenodd`,viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z`}}]},name:`close-circle`,theme:`filled`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(n,u({},e,{ref:t,icon:l.default}))),f=t(e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z`}}]},name:`exclamation-circle`,theme:`filled`}}))());function p(){return p=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},p.apply(this,arguments)}var m=a.forwardRef((e,t)=>a.createElement(n,p({},e,{ref:t,icon:f.default})));export{d as n,c as r,m as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{Or as e,Tr as t,sn as n,tt as r,ur as i}from"./jsx-runtime-NwRKtVrk.js";var a=e(t());function o(e){return t=>a.createElement(r,{theme:{token:{motion:!1,zIndexPopupBase:0}}},a.createElement(e,{...t}))}var s=(e,t,r,s,c)=>o(o=>{let{prefixCls:l,style:u}=o,d=a.useRef(null),[f,p]=a.useState(0),[m,h]=a.useState(0),[g,_]=i(!1,o.open),{getPrefixCls:v}=a.useContext(n),y=v(s||`select`,l);a.useEffect(()=>{if(_(!0),typeof ResizeObserver<`u`){let e=new ResizeObserver(e=>{let t=e[0].target;p(t.offsetHeight+8),h(t.offsetWidth)}),t=setInterval(()=>{let n=c?`.${c(y)}`:`.${y}-dropdown`,r=d.current?.querySelector(n);r&&(clearInterval(t),e.observe(r))},10);return()=>{clearInterval(t),e.disconnect()}}},[y]);let b={...o,style:{...u,margin:0},open:g,getPopupContainer:()=>d.current};r&&(b=r(b)),t&&(b={...b,[t]:{overflow:{adjustX:!1,adjustY:!1}}});let x={paddingBottom:f,position:`relative`,minWidth:m};return a.createElement(`div`,{ref:d,style:x},a.createElement(e,{...b}))});export{o as n,s as t};
-4
View File
@@ -1,4 +0,0 @@
import{Bt as e,J as t,Or as n,Sr as r,St as i,Tr as a,Xn as o,Y as s,cn as c,dn as ee,et as te,fn as l,it as u,m as d,mr as f,r as p}from"./jsx-runtime-NwRKtVrk.js";import{_ as m,g as h,l as g}from"./CopyOutlined-bJLUKQgS.js";import{n as _,t as ne}from"./statusUtils-G17j4Ng-.js";import{i as re,r as ie}from"./Input-yLjViSX8.js";var v=()=>f()&&window.document.documentElement,y=n(a()),b=e=>{let{componentCls:t,paddingLG:n}=e,r=`${t}-textarea`;return{[`textarea${t}`]:{maxWidth:`100%`,height:`auto`,minHeight:e.controlHeight,lineHeight:e.lineHeight,verticalAlign:`bottom`,transition:`all ${e.motionDurationSlow}`,resize:`vertical`,[`&${t}-mouse-active`]:{transition:`all ${e.motionDurationSlow}, height 0s, width 0s`}},[`${t}-textarea-affix-wrapper-resize-dirty`]:{width:`auto`},[r]:{position:`relative`,"&-show-count":{[`${t}-data-count`]:{position:`absolute`,bottom:e.calc(e.fontSize).mul(e.lineHeight).mul(-1).equal(),insetInlineEnd:0,color:e.colorTextDescription,whiteSpace:`nowrap`,pointerEvents:`none`}},[`
&-allow-clear > ${t},
&-affix-wrapper${r}-has-feedback ${t}
`]:{paddingInlineEnd:n},[`&-affix-wrapper${t}-affix-wrapper`]:{padding:0,[`> textarea${t}`]:{fontSize:`inherit`,border:`none`,outline:`none`,background:`transparent`,minHeight:e.calc(e.controlHeight).sub(e.calc(e.lineWidth).mul(2)).equal(),"&:focus":{boxShadow:`none !important`}},[`${t}-suffix`]:{margin:0,"> *:not(:last-child)":{marginInline:0},[`${t}-clear-icon`]:{position:`absolute`,insetInlineEnd:e.paddingInline,insetBlockStart:e.paddingXS},[`${r}-suffix`]:{position:`absolute`,top:0,insetInlineEnd:e.paddingInline,bottom:0,zIndex:1,display:`inline-flex`,alignItems:`center`,margin:`auto`,pointerEvents:`none`}}},[`&-affix-wrapper${t}-affix-wrapper-rtl`]:{[`${t}-suffix`]:{[`${t}-data-count`]:{direction:`ltr`,insetInlineStart:0}}},[`&-affix-wrapper${t}-affix-wrapper-sm`]:{[`${t}-suffix`]:{[`${t}-clear-icon`]:{insetInlineEnd:e.paddingInlineSM}}}}}},x=i([`Input`,`TextArea`],t=>b(e(t,m(t))),h,{resetFont:!1}),S=(0,y.forwardRef)((e,n)=>{let{prefixCls:i,bordered:a=!0,size:f,disabled:m,status:h,allowClear:v,classNames:b,rootClassName:S,className:C,style:w,styles:T,variant:E,showCount:D,onMouseDown:O,onResize:k,...A}=e,{getPrefixCls:j,direction:M,allowClear:N,autoComplete:P,className:F,style:I,classNames:L,styles:R}=c(`textArea`),z=y.useContext(u),ae=m??z,{status:B,hasFeedback:V,feedbackIcon:H}=y.useContext(d),U=ne(B,h),[W,G]=ee([L,b],[R,T],{props:e}),K=y.useRef(null);y.useImperativeHandle(n,()=>({resizableTextArea:K.current?.resizableTextArea,focus:e=>{o(K.current?.resizableTextArea?.textArea,e)},blur:()=>K.current?.blur(),nativeElement:K.current?.nativeElement||null}));let q=j(`input`,i),J=te(q),[Y,oe]=g(q,S);x(q,J);let{compactSize:se,compactItemClassnames:ce}=t(q,M),X=s(e=>f??se??e),[Z,le]=p(`textArea`,E,a),ue=re({allowClear:v,contextAllowClear:N,componentName:`TextArea`}),[Q,$]=y.useState(!1),[de,fe]=y.useState(!1),pe=e=>{$(!0),O?.(e);let t=()=>{$(!1),document.removeEventListener(`mouseup`,t)};document.addEventListener(`mouseup`,t)},me=e=>{if(k?.(e),Q&&l(getComputedStyle)){let e=K.current?.nativeElement?.querySelector(`textarea`);e&&getComputedStyle(e).resize===`both`&&fe(!0)}};return y.createElement(ie,{autoComplete:P,...A,style:{...G.root,...I,...w},styles:G,disabled:ae,allowClear:ue,className:r(oe,J,C,S,ce,F,W.root,{[`${q}-textarea-affix-wrapper-resize-dirty`]:de}),classNames:{...W,textarea:r({[`${q}-sm`]:X===`small`,[`${q}-lg`]:X===`large`},Y,W.textarea,Q&&`${q}-mouse-active`),variant:r({[`${q}-${Z}`]:le},_(q,U)),affixWrapper:r(`${q}-textarea-affix-wrapper`,{[`${q}-affix-wrapper-rtl`]:M===`rtl`,[`${q}-affix-wrapper-sm`]:X===`small`,[`${q}-affix-wrapper-lg`]:X===`large`,[`${q}-textarea-show-count`]:D||e.count?.show},Y)},prefixCls:q,suffix:V&&y.createElement(`span`,{className:`${q}-textarea-suffix`},H),showCount:D,ref:K,onResize:me,onMouseDown:pe})});export{v as n,S as t};
-1
View File
@@ -1 +0,0 @@
import{Er as e,Or as t,Tn as n,Tr as r}from"./jsx-runtime-NwRKtVrk.js";var i=e((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48zM480 416c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v184c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V416zm32 352a48.01 48.01 0 010-96 48.01 48.01 0 010 96z`}}]},name:`warning`,theme:`filled`}})),a=t(r()),o=t(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(n,s({},e,{ref:t,icon:o.default})));export{c as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{Or as e,Tr as t,t as n}from"./jsx-runtime-NwRKtVrk.js";import{r}from"./statusUtils-G17j4Ng-.js";import{t as i}from"./button-CM-rzMOH.js";import{t as a}from"./space-Dvitgohy.js";import{t as o}from"./card-D7XvzVif.js";import{t as s}from"./spin-DV35Mdw9.js";import{t as c}from"./tag-C0Yg14A-.js";import{$ as l,W as u,Y as d,_ as f,g as p,h as m,it as h,s as g}from"./index-B7MUL4ct.js";import{n as _,r as v}from"./agent-D8tKYnoa.js";var y=e(t(),1),b=n(),{Title:x,Text:S,Paragraph:C}=m;function w(){let{t:e}=g(),{token:t}=p.useToken(),{message:n}=l.useApp(),m=h(),[w,T]=(0,y.useState)([]),[E,D]=(0,y.useState)(!1),O=(0,y.useCallback)(async()=>{D(!0);try{T((await _()).data.data??[])}finally{D(!1)}},[]);(0,y.useEffect)(()=>{O()},[O]);let k=async(t,r)=>{try{await v(t,{enabled:r}),T(e=>e.map(e=>e.id===t?{...e,enabled:r}:e)),n.success(e(`ai.agent.update.success`))}catch{n.error(e(`ai.agent.update.failed`))}};return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(`div`,{style:{display:`flex`,justifyContent:`space-between`,alignItems:`flex-start`,marginBottom:t.marginLG},children:(0,b.jsxs)(`div`,{children:[(0,b.jsx)(x,{level:3,style:{marginBottom:t.marginXS},children:e(`ai.agent.page.title`)}),(0,b.jsx)(S,{type:`secondary`,children:e(`ai.agent.page.description`)})]})}),(0,b.jsx)(s,{spinning:E,children:w.length>0?(0,b.jsx)(`div`,{className:`flex flex-wrap gap-6`,children:w.map(n=>(0,b.jsx)(r,{title:n.description,children:(0,b.jsxs)(o,{hoverable:!0,variant:`borderless`,styles:{body:{width:300,padding:20,overflow:`hidden`}},children:[(0,b.jsxs)(`div`,{className:`flex justify-between items-center mb-2.5`,children:[(0,b.jsxs)(a,{align:`center`,children:[(0,b.jsx)(d,{src:n.icon,size:32}),(0,b.jsx)(`span`,{style:{fontWeight:700,fontSize:18},children:n.name})]}),(0,b.jsx)(f,{checked:n.enabled,size:`small`,onChange:e=>k(n.id,e)})]}),(0,b.jsx)(C,{type:`secondary`,ellipsis:{rows:2},style:{marginBottom:t.marginSM},children:n.description}),(0,b.jsx)(a,{size:[4,4],wrap:!0,children:n.tags?.map(e=>(0,b.jsx)(c,{color:`blue`,children:e},e))}),(0,b.jsx)(`div`,{style:{marginTop:t.marginSM},children:(0,b.jsx)(i,{type:`primary`,size:`small`,block:!0,onClick:()=>m(`/ai/chat?agent_id=${n.id}`),children:e(`ai.agent.goChat`)})})]})}))}):(0,b.jsx)(u,{description:e(`ai.agent.empty`)})})]})}export{w as default};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./request-Blbag3Ot.js";async function t(){return e({url:`/ai/agent`,method:`get`})}async function n(t){return e({url:`/ai/agent/${t}`,method:`get`})}async function r(t,n){return e({url:`/ai/agent/${t}`,method:`put`,data:n})}export{t as n,r,n as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More