Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 398bb98356 | |||
| 9a0999742f |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Services\BillDetailService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
||||
* 表尾汇总商品金额/配送费/附加金额/总金额
|
||||
*/
|
||||
class BillExport implements FromCollection, WithStyles
|
||||
{
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
|
||||
private array $specialRows = [];
|
||||
|
||||
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
|
||||
private array $mergeRows = [];
|
||||
|
||||
/** 列头所在行索引 */
|
||||
private int $headerRow = 1;
|
||||
|
||||
/**
|
||||
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store)
|
||||
* @param int $categoryId 一级分类ID(0=全部)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Collection $bills,
|
||||
private readonly int $categoryId = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings)
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
$items = app(BillDetailService::class)->mergedItemsOfBills(
|
||||
$this->bills->pluck('id')->map(static fn ($id): int => (int) $id)->all()
|
||||
);
|
||||
|
||||
$categoryName = '全部';
|
||||
if ($this->categoryId > 0) {
|
||||
[$items, $categoryName] = $this->filterByRootCategory($items);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行
|
||||
$rows[] = ['门店账单合并导出(共 ' . $this->bills->count() . ' 张)'];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 范围行:账单号 / 门店 / 分类 / 导出时间
|
||||
$billNos = $this->bills->pluck('bill_no')->all();
|
||||
$billNoText = count($billNos) > 8
|
||||
? implode('、', array_slice($billNos, 0, 8)) . ' 等 ' . count($billNos) . ' 张'
|
||||
: implode('、', $billNos);
|
||||
$storeNames = $this->bills
|
||||
->map(static fn (BillModel $bill): string => $bill->store->name ?? ('门店#' . $bill->store_id))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$rows[] = ['账单号:' . $billNoText];
|
||||
$this->specialRows[++$rowIndex] = 'scope';
|
||||
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
|
||||
$this->specialRows[++$rowIndex] = 'scope';
|
||||
|
||||
// 空行
|
||||
$rows[] = [''];
|
||||
$rowIndex++;
|
||||
|
||||
// 列头
|
||||
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '金额'];
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行(跨账单同商品已合并,单价为加权平均)
|
||||
$totalQuantity = 0;
|
||||
$totalWeight = '0';
|
||||
$totalAmount = '0';
|
||||
foreach (array_values($items) as $sort => $item) {
|
||||
$rows[] = [
|
||||
$sort + 1,
|
||||
$item['product_name'],
|
||||
$item['product_spec'],
|
||||
$item['unit'],
|
||||
(float) $item['price'],
|
||||
(int) $item['quantity'],
|
||||
(float) $item['weight'],
|
||||
(float) $item['amount'],
|
||||
];
|
||||
$rowIndex++;
|
||||
$totalQuantity += (int) $item['quantity'];
|
||||
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
|
||||
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
||||
}
|
||||
|
||||
// 商品合计行
|
||||
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
|
||||
// 配送费/附加金额/总金额(账单级费用全额汇总,不受分类过滤影响)
|
||||
$deliveryTotal = '0';
|
||||
$addedTotal = '0';
|
||||
$boxNum = 0;
|
||||
$trayNum = 0;
|
||||
foreach ($this->bills as $bill) {
|
||||
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
|
||||
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
|
||||
$boxNum += (int) $bill->box_num;
|
||||
$trayNum += (int) $bill->tray_num;
|
||||
}
|
||||
$grandTotal = bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2);
|
||||
|
||||
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
$this->mergeRows[] = $rowIndex;
|
||||
|
||||
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
$this->mergeRows[] = $rowIndex;
|
||||
|
||||
$rows[] = ['总金额(商品金额+配送费+附加金额)', '', '', '', '', '', '', (float) $grandTotal];
|
||||
$this->specialRows[++$rowIndex] = 'grand';
|
||||
$this->mergeRows[] = $rowIndex;
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
$sheet->getColumnDimension('A')->setWidth(6);
|
||||
$sheet->getColumnDimension('B')->setWidth(24);
|
||||
$sheet->getColumnDimension('C')->setWidth(14);
|
||||
$sheet->getColumnDimension('D')->setWidth(8);
|
||||
$sheet->getColumnDimension('E')->setWidth(10);
|
||||
$sheet->getColumnDimension('F')->setWidth(10);
|
||||
$sheet->getColumnDimension('G')->setWidth(12);
|
||||
$sheet->getColumnDimension('H')->setWidth(12);
|
||||
|
||||
foreach ($this->mergeRows as $row) {
|
||||
$sheet->mergeCells('A' . $row . ':G' . $row);
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
foreach ($this->specialRows as $row => $type) {
|
||||
$style = match ($type) {
|
||||
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||
'header', 'summary' => ['font' => ['bold' => true]],
|
||||
'grand' => ['font' => ['bold' => true, 'size' => 12]],
|
||||
default => [],
|
||||
};
|
||||
if ($style !== []) {
|
||||
$styles[$row] = $style;
|
||||
}
|
||||
}
|
||||
|
||||
return $styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
|
||||
* 仅保留根分类为所选分类的行
|
||||
*
|
||||
* @param array<int, array> $items 合并后的商品明细
|
||||
* @return array{0: array<int, array>, 1: string} [过滤后明细, 一级分类名]
|
||||
*/
|
||||
private function filterByRootCategory(array $items): array
|
||||
{
|
||||
$categories = ProductCategoryModel::all()->keyBy('id');
|
||||
$categoryName = $categories->get($this->categoryId)->name ?? ('分类#' . $this->categoryId);
|
||||
|
||||
$productCategoryIds = ProductModel::withTrashed()
|
||||
->whereIn('id', array_column($items, 'product_id'))
|
||||
->pluck('category_id', 'id');
|
||||
|
||||
// 商品ID => 根分类ID(与采购单导出同口径的上溯解析)
|
||||
$rootIds = [];
|
||||
foreach ($productCategoryIds as $productId => $categoryId) {
|
||||
$rootId = 0;
|
||||
$cursor = (int) $categoryId;
|
||||
$guard = 0;
|
||||
while ($cursor > 0 && $guard++ < 20) {
|
||||
$category = $categories->get($cursor);
|
||||
if ($category === null) {
|
||||
break;
|
||||
}
|
||||
$rootId = (int) $category->id;
|
||||
$cursor = (int) $category->parent_id;
|
||||
}
|
||||
$rootIds[(int) $productId] = $rootId;
|
||||
}
|
||||
|
||||
$categoryId = $this->categoryId;
|
||||
$filtered = array_values(array_filter(
|
||||
$items,
|
||||
static fn (array $row): bool => ($rootIds[$row['product_id']] ?? 0) === $categoryId
|
||||
));
|
||||
|
||||
if ($filtered === []) {
|
||||
throw new RepositoryException('所选账单在「' . $categoryName . '」分类下无商品明细');
|
||||
}
|
||||
|
||||
return [$filtered, $categoryName];
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\BillExport;
|
||||
use App\Models\BillModel;
|
||||
use App\Services\BillDetailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 小程序门店账单(采购单完成后由后台生成,门店端只读)
|
||||
@@ -16,28 +19,114 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class BillController extends BaseMiniController
|
||||
{
|
||||
/** 账单列表:当前门店强制过滤,?status= 按支付状态筛选(0未支付 1已支付) */
|
||||
/**
|
||||
* 账单列表:当前门店强制过滤
|
||||
* ?status= 支付状态(0未支付 1已支付);?payable=1 仅可发起付款(未支付且未在审核中,供合并付款选择页);
|
||||
* ?start_date=&end_date= 账单日期区间;?page=&pageSize= 分页(pageSize 上限 50)
|
||||
* 响应附加 summary:待支付笔数/金额(仅按门店口径,不受列表筛选影响)
|
||||
*/
|
||||
#[GetRoute('/bill', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->validate([
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'payable' => 'nullable|boolean',
|
||||
'start_date' => 'nullable|date_format:Y-m-d',
|
||||
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'pageSize' => 'nullable|integer|min:1|max:50',
|
||||
], [
|
||||
'status.in' => '支付状态不正确',
|
||||
'start_date.date_format' => '开始日期格式为 Y-m-d',
|
||||
'end_date.date_format' => '结束日期格式为 Y-m-d',
|
||||
'end_date.after_or_equal' => '结束日期不能早于开始日期',
|
||||
'pageSize.max' => '每页数量最大 50',
|
||||
]);
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$query = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->with('purchase:id,purchase_no,purchase_date');
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (int) $request->input('status'));
|
||||
if (isset($params['status'])) {
|
||||
$query->where('status', (int) $params['status']);
|
||||
}
|
||||
if ($request->boolean('payable')) {
|
||||
$query->where('status', BillModel::STATUS_UNPAID)->where('payment_id', 0);
|
||||
}
|
||||
if (! empty($params['start_date'])) {
|
||||
$query->whereDate('bill_date', '>=', $params['start_date']);
|
||||
}
|
||||
if (! empty($params['end_date'])) {
|
||||
$query->whereDate('bill_date', '<=', $params['end_date']);
|
||||
}
|
||||
|
||||
$data = $query->orderBy('bill_date', 'desc')
|
||||
$paginator = $query->orderBy('bill_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate((int) $request->input('pageSize', 10))
|
||||
->toArray();
|
||||
->paginate((int) ($params['pageSize'] ?? 10));
|
||||
|
||||
$cycleDays = (int) $store->payment_cycle_days;
|
||||
$paginator->getCollection()->transform(
|
||||
fn (BillModel $bill): array => $this->formatBill($bill, $cycleDays)
|
||||
);
|
||||
|
||||
// 待支付汇总(合并付款入口的头部统计)
|
||||
$unpaid = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->where('status', BillModel::STATUS_UNPAID)
|
||||
->selectRaw('COUNT(*) as aggregate_count, COALESCE(SUM(total_amount), 0) as aggregate_amount')
|
||||
->first();
|
||||
|
||||
$data = $paginator->toArray();
|
||||
$data['summary'] = [
|
||||
'unpaid_count' => (int) $unpaid->aggregate_count,
|
||||
'unpaid_amount' => bcadd((string) $unpaid->aggregate_amount, '0', 2),
|
||||
];
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并导出账单(静态路由放在 /bill/{id} 之前声明)
|
||||
* 仅可导出本店账单(强制 store_id 过滤,张数不匹配说明混入无效账单整批拒绝);
|
||||
* ?ids=1,2,3 必选(1~100 张);?category_id= 一级分类过滤(0/缺省=全部)
|
||||
*/
|
||||
#[GetRoute('/bill/export', authorize: true)]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|string',
|
||||
'category_id' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $data['ids'])))));
|
||||
if ($ids === [] || count($ids) > 100) {
|
||||
throw new RepositoryException('请选择 1~100 张账单');
|
||||
}
|
||||
|
||||
$bills = BillModel::with('store:id,name')
|
||||
->where('store_id', $store->id)
|
||||
->whereIn('id', $ids)
|
||||
->orderBy('bill_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
if ($bills->isEmpty()) {
|
||||
throw new RepositoryException('账单不存在');
|
||||
}
|
||||
if ($bills->count() !== count($ids)) {
|
||||
throw new RepositoryException('存在无效账单,请刷新后重试');
|
||||
}
|
||||
|
||||
return Excel::download(
|
||||
new BillExport($bills, (int) ($data['category_id'] ?? 0)),
|
||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx'
|
||||
);
|
||||
}
|
||||
|
||||
/** 账单详情(校验归属:仅能查看本店账单;含合并后的商品明细与关联订单) */
|
||||
#[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id, Request $request): JsonResponse
|
||||
@@ -58,9 +147,48 @@ class BillController extends BaseMiniController
|
||||
->toArray();
|
||||
|
||||
return $this->success([
|
||||
'bill' => $bill->toArray(),
|
||||
'bill' => $this->formatBill($bill, (int) $store->payment_cycle_days),
|
||||
'items' => app(BillDetailService::class)->mergedItems($bill),
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单输出格式化(列表行与详情共用)
|
||||
* 推导支付进度(待支付/审核中/已支付),按门店回款周期计算应结算日期;
|
||||
* 剔除收款操作人等后台字段
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatBill(BillModel $bill, int $cycleDays): array
|
||||
{
|
||||
$payState = $bill->payState();
|
||||
|
||||
return [
|
||||
'id' => $bill->id,
|
||||
'bill_no' => $bill->bill_no,
|
||||
'bill_date' => $bill->bill_date->toDateString(),
|
||||
'purchase_id' => $bill->purchase_id,
|
||||
'purchase' => $bill->purchase?->toArray(),
|
||||
'product_amount' => $bill->product_amount,
|
||||
'delivery_fee' => $bill->delivery_fee,
|
||||
'box_num' => $bill->box_num,
|
||||
'tray_num' => $bill->tray_num,
|
||||
'box_price' => $bill->box_price,
|
||||
'tray_price' => $bill->tray_price,
|
||||
'added_amount' => $bill->added_amount,
|
||||
'total_amount' => $bill->total_amount,
|
||||
'status' => $bill->status,
|
||||
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
|
||||
'pay_state' => $payState,
|
||||
'pay_state_name' => BillModel::PAY_STATE_NAMES[$payState],
|
||||
'can_pay' => $payState === BillModel::PAY_STATE_UNPAID,
|
||||
'payment_id' => $bill->payment_id,
|
||||
'settlement_date' => $bill->bill_date->copy()->addDays($cycleDays)->toDateString(),
|
||||
'paid_at' => $bill->paid_at?->toDateTimeString(),
|
||||
'pay_remark' => $bill->pay_remark,
|
||||
'remark' => $bill->remark,
|
||||
'created_at' => $bill->created_at?->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,24 +128,81 @@ class OrderController extends BaseMiniController
|
||||
], '下单成功');
|
||||
}
|
||||
|
||||
/** 历史订单:当前门店强制过滤,?status=&page=&pageSize= */
|
||||
/**
|
||||
* 历史订单:当前门店强制过滤
|
||||
* ?status= 状态筛选;?start_date=&end_date= 订货日期区间(配合 /order/summary 周期下钻);
|
||||
* ?page=&pageSize= 分页(pageSize 上限 50)
|
||||
* 行数据附带 status_name / can_cancel / 商品预览(前 3 条明细)/ item_count,完整明细走详情接口
|
||||
*/
|
||||
#[GetRoute('/order', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->validate([
|
||||
'status' => 'nullable|integer|in:0,1,2,3,4,9',
|
||||
'start_date' => 'nullable|date_format:Y-m-d',
|
||||
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'pageSize' => 'nullable|integer|min:1|max:50',
|
||||
], [
|
||||
'status.in' => '订单状态不正确',
|
||||
'start_date.date_format' => '开始日期格式为 Y-m-d',
|
||||
'end_date.date_format' => '结束日期格式为 Y-m-d',
|
||||
'end_date.after_or_equal' => '结束日期不能早于开始日期',
|
||||
'pageSize.max' => '每页数量最大 50',
|
||||
]);
|
||||
|
||||
$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'));
|
||||
$query = StoreOrderModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->select([
|
||||
'id', 'order_no', 'order_date', 'total_quantity', 'total_weight',
|
||||
'total_amount', 'status', 'remark', 'purchase_id', 'bill_id', 'created_at',
|
||||
])
|
||||
->with(['items' => static fn ($itemsQuery) => $itemsQuery
|
||||
->select(['id', 'order_id', 'product_name', 'quantity', 'unit'])
|
||||
->orderBy('id')]);
|
||||
if (isset($params['status'])) {
|
||||
$query->where('status', (int) $params['status']);
|
||||
}
|
||||
if (! empty($params['start_date'])) {
|
||||
$query->whereDate('order_date', '>=', $params['start_date']);
|
||||
}
|
||||
if (! empty($params['end_date'])) {
|
||||
$query->whereDate('order_date', '<=', $params['end_date']);
|
||||
}
|
||||
|
||||
$data = $query->orderBy('order_date', 'desc')
|
||||
$paginator = $query->orderBy('order_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate((int) $request->input('pageSize', 10))
|
||||
->toArray();
|
||||
->paginate((int) ($params['pageSize'] ?? 10));
|
||||
|
||||
return $this->success($data);
|
||||
$paginator->getCollection()->transform(
|
||||
static fn (StoreOrderModel $order): array => [
|
||||
'id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'order_date' => $order->order_date->toDateString(),
|
||||
'status' => $order->status,
|
||||
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? '',
|
||||
'can_cancel' => $order->status === StoreOrderModel::STATUS_PENDING,
|
||||
'total_quantity' => $order->total_quantity,
|
||||
'total_weight' => $order->total_weight,
|
||||
'total_amount' => $order->total_amount,
|
||||
'remark' => $order->remark,
|
||||
'purchase_id' => $order->purchase_id,
|
||||
'bill_id' => $order->bill_id,
|
||||
'created_at' => $order->created_at?->toDateTimeString(),
|
||||
'item_count' => $order->items->count(),
|
||||
'items' => $order->items->take(3)
|
||||
->map(static fn (StoreOrderItemModel $item): array => [
|
||||
'product_name' => $item->product_name,
|
||||
'quantity' => $item->quantity,
|
||||
'unit' => $item->unit,
|
||||
])->values()->all(),
|
||||
]
|
||||
);
|
||||
|
||||
return $this->success($paginator->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\BillExport;
|
||||
use App\Models\BillModel;
|
||||
use App\Services\BillDetailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 门店账单管理(采购单完成后按门店生成,后台查看 + 线下收款登记)
|
||||
@@ -55,6 +58,38 @@ class BillController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
||||
* 表尾汇总商品金额/配送费/附加金额/总金额
|
||||
*/
|
||||
#[GetRoute(route: '/export', authorize: 'export')]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|string',
|
||||
'category_id' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$ids = array_values(array_filter(array_map('intval', explode(',', (string) $data['ids']))));
|
||||
if ($ids === [] || count($ids) > 100) {
|
||||
throw new RepositoryException('请选择 1~100 张账单');
|
||||
}
|
||||
|
||||
$bills = BillModel::with('store:id,name')
|
||||
->whereIn('id', $ids)
|
||||
->orderBy('bill_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
if ($bills->isEmpty()) {
|
||||
throw new RepositoryException('账单不存在');
|
||||
}
|
||||
|
||||
return Excel::download(
|
||||
new BillExport($bills, (int) ($data['category_id'] ?? 0)),
|
||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单详情:账单信息 + 合并后的商品明细(按商品聚合)+ 关联订单
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,32 @@ class BillModel extends Model
|
||||
self::STATUS_PAID => '已支付',
|
||||
];
|
||||
|
||||
/** 支付进度(小程序端):待支付(可发起合并付款) */
|
||||
public const int PAY_STATE_UNPAID = 0;
|
||||
/** 支付进度:审核中(已提交合并付款凭证,待后台审核;审核拒绝后释放回待支付) */
|
||||
public const int PAY_STATE_REVIEWING = 1;
|
||||
/** 支付进度:已支付 */
|
||||
public const int PAY_STATE_PAID = 2;
|
||||
|
||||
/** 支付进度中文名 */
|
||||
public const array PAY_STATE_NAMES = [
|
||||
self::PAY_STATE_UNPAID => '待支付',
|
||||
self::PAY_STATE_REVIEWING => '审核中',
|
||||
self::PAY_STATE_PAID => '已支付',
|
||||
];
|
||||
|
||||
/**
|
||||
* 支付进度推导:已支付 > 审核中(payment_id 锁定中)> 待支付
|
||||
* (支付审核拒绝后 payment_id 释放为 0,回到待支付;线下收款直接置已支付)
|
||||
*/
|
||||
public function payState(): int
|
||||
{
|
||||
if ($this->status === self::STATUS_PAID) {
|
||||
return self::PAY_STATE_PAID;
|
||||
}
|
||||
return $this->payment_id > 0 ? self::PAY_STATE_REVIEWING : self::PAY_STATE_UNPAID;
|
||||
}
|
||||
|
||||
protected $table = 'bill';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 账单详情数据组装(后台门店账单页与小程序账单详情共用)
|
||||
@@ -24,6 +25,35 @@ class BillDetailService
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $this->aggregateItems($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并后的商品明细(跨账单口径):按商品聚合多张账单的全部订单明细
|
||||
* 用于账单合并导出,单价同为加权平均口径
|
||||
*
|
||||
* @param array<int, int> $billIds 账单ID列表
|
||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
||||
*/
|
||||
public function mergedItemsOfBills(array $billIds): array
|
||||
{
|
||||
$items = StoreOrderItemModel::query()
|
||||
->whereIn('bill_id', $billIds)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $this->aggregateItems($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商品聚合订单明细:数量累加、重量/金额 bc 累加、单价加权平均,
|
||||
* 按「分类sort → 商品sort」排序
|
||||
*
|
||||
* @param Collection<int, StoreOrderItemModel> $items
|
||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
||||
*/
|
||||
private function aggregateItems(Collection $items): array
|
||||
{
|
||||
// 排序键:分类 sort → 商品 sort(与采购单明细矩阵同序)
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
|
||||
@@ -213,6 +213,7 @@ class PermissionSeeder extends Seeder
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'recon.bill.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.bill.pay', 'name' => '确认收款'],
|
||||
['type' => 'rule', 'key' => 'recon.bill.export', 'name' => '导出'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# 小程序首页接口文档
|
||||
|
||||
> 适用端:微信小程序(h5 仓库,Taro)
|
||||
> 后台配置入口:PC 后台「客户端配置」菜单(首页轮播图 / 宫格导航 / 促销推荐卡片)
|
||||
> 更新日期:2026-08-14
|
||||
|
||||
## 通用约定
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 根地址 | `BASE_URL`(见 `src/utils/request.ts`,如 `http://localhost:8000/index.php`) |
|
||||
| 认证 | 请求头 `Authorization: Bearer {token}`,token 来自登录接口,本地存储 key `auth_token` |
|
||||
| 响应包络 | `{ success: boolean, data: T, msg?: string, showType?: number }` |
|
||||
| 失败处理 | `success=false` 时 `msg` 为中文错误信息;HTTP 401 表示登录过期,需重新登录 |
|
||||
|
||||
## GET /mini/home
|
||||
|
||||
首页配置聚合接口:一次返回轮播图、宫格导航、促销推荐卡片三组数据,均为**启用状态(status=1)**且按 `sort` 升序(越小越靠前)。
|
||||
|
||||
- **权限**:需登录(Bearer token)
|
||||
- **请求参数**:无
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"banners": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "新鲜直采",
|
||||
"image_id": 123,
|
||||
"link": "/pages/goods/detail?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/xxx.jpg"
|
||||
}
|
||||
],
|
||||
"navs": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "蔬菜专区",
|
||||
"image_id": 124,
|
||||
"link": "/pages/category/index?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/yyy.png"
|
||||
}
|
||||
],
|
||||
"promos": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "限时特惠",
|
||||
"sub_title": "全场 8 折起",
|
||||
"image_id": 125,
|
||||
"link": "/pages/promo/detail?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/zzz.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
**banners(轮播图)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 轮播图 ID |
|
||||
| title | string | 标题(后台维护,可用于无障碍/占位) |
|
||||
| image_id | number | 图片文件 ID(sys_file),一般无需使用 |
|
||||
| **image_url** | string \| null | 轮播图片完整 URL,直接用于 `<Image src>`;未传图时为 null |
|
||||
| link | string | 小程序页面跳转路径,**空字符串表示点击不跳转** |
|
||||
| sort | number | 排序值(已按此升序返回,前端无需再排) |
|
||||
|
||||
**navs(宫格导航)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 导航 ID |
|
||||
| name | string | 导航名称(宫格文字) |
|
||||
| **image_url** | string \| null | 导航图标完整 URL |
|
||||
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||
| sort | number | 排序值 |
|
||||
|
||||
**promos(促销推荐卡片)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 卡片 ID |
|
||||
| title | string | 卡片标题 |
|
||||
| sub_title | string | 副标题/促销文案,可能为空字符串 |
|
||||
| **image_url** | string \| null | 卡片图片完整 URL |
|
||||
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||
| sort | number | 排序值 |
|
||||
|
||||
### 前端接入示例
|
||||
|
||||
`src/services/home.ts`(新增):
|
||||
|
||||
```ts
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
/** 首页轮播图项 */
|
||||
export interface HomeBanner {
|
||||
id: number
|
||||
title: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页宫格导航项 */
|
||||
export interface HomeNav {
|
||||
id: number
|
||||
name: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页促销推荐卡片 */
|
||||
export interface HomePromo {
|
||||
id: number
|
||||
title: string
|
||||
sub_title: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页配置聚合数据 */
|
||||
export interface HomeConfig {
|
||||
banners: HomeBanner[]
|
||||
navs: HomeNav[]
|
||||
promos: HomePromo[]
|
||||
}
|
||||
|
||||
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||
export function getHomeConfigApi() {
|
||||
return get<HomeConfig>('/mini/home')
|
||||
}
|
||||
```
|
||||
|
||||
页面中使用(跳转需兼容空链接):
|
||||
|
||||
```tsx
|
||||
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||
|
||||
useEffect(() => {
|
||||
getHomeConfigApi().then(res => setConfig(res.data))
|
||||
}, [])
|
||||
|
||||
/** 统一跳转:link 为空不跳转 */
|
||||
function handleLink(link: string) {
|
||||
if (!link) return
|
||||
Taro.navigateTo({ url: link })
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **三组数据均可能为空数组**(后台未配置或全部停用),页面需做空态处理。
|
||||
2. `image_url` 可能为 `null`(后台未上传图片),渲染前判空。
|
||||
3. `link` 为小程序内部页面路径(以 `/` 开头),用 `Taro.navigateTo` 跳转;若目标为 tabBar 页面需改用 `Taro.switchTab`(建议后台配置时避免填 tabBar 路径)。
|
||||
4. 数据实时生效:后台修改后,小程序下次进入首页请求即为最新内容,无缓存。
|
||||
5. 接口需登录后调用;未登录(401)会由 request 封装自动跳登录页。
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exports\BillExport;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\UserModel;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
/**
|
||||
* 门店账单合并导出:跨账单按商品合并明细(加权平均单价)/ 一级分类过滤 /
|
||||
* 配送费与附加金额全额汇总 / ids 校验 / 权限拦截 / 分类下无明细拒绝
|
||||
*/
|
||||
class BillExportTest extends ProcurementTestCase
|
||||
{
|
||||
private static int $billSeq = 0;
|
||||
|
||||
/** 造一张账单(默认配送费 10、筐 2×5 + 托盘 1×20 = 附加 30、总额 140,未支付) */
|
||||
private function makeBill(StoreModel $store, array $attributes = []): BillModel
|
||||
{
|
||||
$seq = ++self::$billSeq;
|
||||
|
||||
return BillModel::create(array_merge([
|
||||
'bill_no' => 'ZD' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
|
||||
'store_id' => $store->id,
|
||||
'bill_date' => '2026-08-10',
|
||||
'product_amount' => '100.00',
|
||||
'delivery_fee' => '10.00',
|
||||
'box_num' => 2,
|
||||
'tray_num' => 1,
|
||||
'box_price' => '5.00',
|
||||
'tray_price' => '20.00',
|
||||
'added_amount' => '30.00',
|
||||
'total_amount' => '140.00',
|
||||
'status' => BillModel::STATUS_UNPAID,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 造一条账单明细(快照品名/包规/单位取自商品档案) */
|
||||
private function makeItem(BillModel $bill, ProductModel $product, int $quantity, string $price): StoreOrderItemModel
|
||||
{
|
||||
return StoreOrderItemModel::factory()->create([
|
||||
'bill_id' => $bill->id,
|
||||
'store_id' => $bill->store_id,
|
||||
'product_id' => $product->id,
|
||||
'category_id' => $product->category_id,
|
||||
'product_name' => $product->name,
|
||||
'product_spec' => $product->spec,
|
||||
'unit' => $product->unit,
|
||||
'price' => $price,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $quantity * 10,
|
||||
'amount' => bcmul($price, (string) $quantity, 2),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 跨账单合并:同商品数量累加、单价加权平均,配送费/附加/总金额全额汇总 */
|
||||
public function test_export_merges_items_across_bills(): void
|
||||
{
|
||||
$this->freezeTime();
|
||||
|
||||
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$store = StoreModel::factory()->create();
|
||||
$productA = ProductModel::factory()->create(['category_id' => $root->id]);
|
||||
$productB = ProductModel::factory()->create(['category_id' => $root->id]);
|
||||
|
||||
$bill1 = $this->makeBill($store);
|
||||
$bill2 = $this->makeBill($store);
|
||||
// 同一商品跨账单:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37
|
||||
$this->makeItem($bill1, $productA, 10, '2.00');
|
||||
$this->makeItem($bill2, $productA, 6, '3.00');
|
||||
$this->makeItem($bill2, $productB, 4, '5.00');
|
||||
|
||||
$this->actingAsSysUser();
|
||||
Excel::fake();
|
||||
$this->get("/recon/bill/export?ids={$bill1->id},{$bill2->id}")->assertOk();
|
||||
|
||||
Excel::assertDownloaded(
|
||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
|
||||
static function (BillExport $export) use ($productA, $productB): bool {
|
||||
$rows = $export->collection()->values();
|
||||
// 标题1 + 范围2 + 空行1 + 列头1 + 明细2 + 合计4 = 11 行
|
||||
if ($rows->count() !== 11) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$details = $rows->slice(5, 2)->values();
|
||||
$rowA = $details->firstWhere(1, $productA->name);
|
||||
$rowB = $details->firstWhere(1, $productB->name);
|
||||
if ($rowA === null || $rowB === null) {
|
||||
return false;
|
||||
}
|
||||
// 跨账单同商品合并:数量 16、加权单价 2.37、金额 38.00
|
||||
if ((float) $rowA[4] !== 2.37 || (int) $rowA[5] !== 16 || (float) $rowA[7] !== 38.0) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $rowB[5] !== 4 || (float) $rowB[7] !== 20.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summary = $rows->slice(7)->values();
|
||||
// 合计:数量 20、商品金额 58.00;配送费 20.00;附加 60.00(筐 4 / 托盘 2);总金额 138.00
|
||||
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) {
|
||||
return false;
|
||||
}
|
||||
if ($summary[1][0] !== '配送费合计' || (float) $summary[1][7] !== 20.0) {
|
||||
return false;
|
||||
}
|
||||
if (! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个') || (float) $summary[2][7] !== 60.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (float) $summary[3][7] === 138.0;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 一级分类过滤:子分类商品归入根分类,其他分类商品忽略;费用行仍全额汇总 */
|
||||
public function test_export_filters_by_root_category(): void
|
||||
{
|
||||
$this->freezeTime();
|
||||
|
||||
$vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$vegChild = ProductCategoryModel::create(['name' => '叶菜类', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]);
|
||||
$meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$veg = ProductModel::factory()->create(['category_id' => $vegChild->id]);
|
||||
$meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]);
|
||||
|
||||
$bill = $this->makeBill($store);
|
||||
$this->makeItem($bill, $veg, 10, '2.00');
|
||||
$this->makeItem($bill, $meat, 5, '20.00');
|
||||
|
||||
$this->actingAsSysUser();
|
||||
Excel::fake();
|
||||
$this->get("/recon/bill/export?ids={$bill->id}&category_id={$vegRoot->id}")->assertOk();
|
||||
|
||||
Excel::assertDownloaded(
|
||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
|
||||
static function (BillExport $export) use ($veg): bool {
|
||||
$rows = $export->collection()->values();
|
||||
// 范围行标注所选分类
|
||||
if (! str_contains((string) $rows[2][0], '分类:蔬菜')) {
|
||||
return false;
|
||||
}
|
||||
// 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略
|
||||
$detail = $rows->slice(5, 1)->values()->first();
|
||||
if ($detail === null || $detail[1] !== $veg->name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summary = $rows->slice(6)->values();
|
||||
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 全额;总金额 60.00
|
||||
if ((float) $summary[0][7] !== 20.0) {
|
||||
return false;
|
||||
}
|
||||
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (float) $summary[3][7] === 60.0;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 分类过滤后无明细 → 拒绝(真实下载链路,导出类抛业务异常) */
|
||||
public function test_export_empty_category_rejected(): void
|
||||
{
|
||||
$vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]);
|
||||
|
||||
$bill = $this->makeBill($store);
|
||||
$this->makeItem($bill, $meat, 5, '20.00');
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->get("/recon/bill/export?ids={$bill->id}&category_id={$vegRoot->id}")
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 无 recon.bill.export 权限点 → 拦截 */
|
||||
public function test_export_requires_export_permission(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store);
|
||||
|
||||
// 先建一个占位用户:每个测试方法内首个系统用户自增 id=1,
|
||||
// SysAccessToken::can() 对 tokenable_id==1(超管)放行全部权限,会绕过 abilities 校验
|
||||
$this->actingAsSysUser();
|
||||
// 仅持有查询权限的用户
|
||||
$this->actingAsSysUser(['recon.bill.query']);
|
||||
|
||||
$response = $this->get("/recon/bill/export?ids={$bill->id}");
|
||||
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
||||
}
|
||||
|
||||
/** ids 缺失 / 超 100 张 / 账单不存在 → 拒绝 */
|
||||
public function test_export_invalid_ids_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->get('/recon/bill/export')
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->get('/recon/bill/export?ids=' . implode(',', range(1, 101)))
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->get('/recon/bill/export?ids=99999')
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 小程序端导出:强制本店过滤,混入他店账单整批拒绝 */
|
||||
public function test_mini_export_scoped_to_bound_store(): void
|
||||
{
|
||||
$this->freezeTime();
|
||||
|
||||
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$storeA = StoreModel::factory()->create();
|
||||
$storeB = StoreModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['category_id' => $root->id]);
|
||||
|
||||
$billA = $this->makeBill($storeA);
|
||||
$billB = $this->makeBill($storeB);
|
||||
$this->makeItem($billA, $product, 10, '2.00');
|
||||
$this->makeItem($billB, $product, 5, '20.00');
|
||||
|
||||
$user = UserModel::factory()->forStore($storeA->id)->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
// 本店账单正常导出
|
||||
Excel::fake();
|
||||
$this->get("/mini/bill/export?ids={$billA->id}")->assertOk();
|
||||
Excel::assertDownloaded(
|
||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
|
||||
static function (BillExport $export): bool {
|
||||
$rows = $export->collection()->values();
|
||||
// 明细仅本店账单的 1 行(他店账单未混入)
|
||||
return $rows->count() === 10;
|
||||
}
|
||||
);
|
||||
|
||||
// 混入他店账单 → 张数不匹配整批拒绝
|
||||
$this->get("/mini/bill/export?ids={$billA->id},{$billB->id}")
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
// 仅他店账单 → 本店口径查无账单
|
||||
$this->get("/mini/bill/export?ids={$billB->id}")
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 小程序端导出:未绑定门店 → 拒绝 */
|
||||
public function test_mini_export_requires_store_binding(): void
|
||||
{
|
||||
$user = UserModel::factory()->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->get('/mini/bill/export?ids=1')
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序账单:支付进度推导(待支付/审核中/已支付)、应结算日期(回款周期)、
|
||||
* 待支付汇总、可付款筛选、门店数据隔离、详情归属校验
|
||||
*/
|
||||
class MiniBillTest extends ProcurementTestCase
|
||||
{
|
||||
private static int $billSeq = 0;
|
||||
|
||||
/**
|
||||
* 造一个绑定正常门店的小程序用户
|
||||
*
|
||||
* @return array{0: StoreModel, 1: UserModel}
|
||||
*/
|
||||
private function makeStoreWithUser(int $cycleDays = 3): array
|
||||
{
|
||||
$store = StoreModel::factory()->paymentCycle($cycleDays)->create();
|
||||
|
||||
return [$store, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 造一张账单(默认 2026-08-10 出账,总额 140.00,未支付) */
|
||||
private function makeBill(StoreModel $store, array $attributes = []): BillModel
|
||||
{
|
||||
$seq = ++self::$billSeq;
|
||||
|
||||
return BillModel::create(array_merge([
|
||||
'bill_no' => 'ZD' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
|
||||
'store_id' => $store->id,
|
||||
'bill_date' => '2026-08-10',
|
||||
'product_amount' => '100.00',
|
||||
'delivery_fee' => '10.00',
|
||||
'box_num' => 2,
|
||||
'tray_num' => 1,
|
||||
'box_price' => '5.00',
|
||||
'tray_price' => '20.00',
|
||||
'added_amount' => '30.00',
|
||||
'total_amount' => '140.00',
|
||||
'status' => BillModel::STATUS_UNPAID,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 支付进度推导与应结算日期(账单日期 + 门店回款周期) */
|
||||
public function test_bill_list_derives_pay_state_and_settlement_date(): void
|
||||
{
|
||||
[$store, $user] = $this->makeStoreWithUser(3);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$unpaid = $this->makeBill($store);
|
||||
$reviewing = $this->makeBill($store, ['payment_id' => 99]);
|
||||
$paid = $this->makeBill($store, ['status' => BillModel::STATUS_PAID, 'paid_at' => '2026-08-12 10:00:00']);
|
||||
|
||||
$rows = collect($this->getJson('/mini/bill')->assertOk()->json('data.data'))->keyBy('id');
|
||||
|
||||
$unpaidRow = $rows[$unpaid->id];
|
||||
$this->assertSame(BillModel::PAY_STATE_UNPAID, $unpaidRow['pay_state']);
|
||||
$this->assertSame('待支付', $unpaidRow['pay_state_name']);
|
||||
$this->assertTrue($unpaidRow['can_pay']);
|
||||
$this->assertSame('2026-08-13', $unpaidRow['settlement_date'], '账单日期 2026-08-10 + 回款周期 3 天');
|
||||
$this->assertArrayNotHasKey('operator_id', $unpaidRow, '小程序端不输出后台操作人字段');
|
||||
$this->assertArrayNotHasKey('paid_operator_id', $unpaidRow);
|
||||
|
||||
$reviewingRow = $rows[$reviewing->id];
|
||||
$this->assertSame(BillModel::PAY_STATE_REVIEWING, $reviewingRow['pay_state']);
|
||||
$this->assertSame('审核中', $reviewingRow['pay_state_name']);
|
||||
$this->assertFalse($reviewingRow['can_pay']);
|
||||
|
||||
$paidRow = $rows[$paid->id];
|
||||
$this->assertSame(BillModel::PAY_STATE_PAID, $paidRow['pay_state']);
|
||||
$this->assertSame('已支付', $paidRow['pay_state_name']);
|
||||
$this->assertFalse($paidRow['can_pay']);
|
||||
$this->assertSame('2026-08-12 10:00:00', $paidRow['paid_at']);
|
||||
}
|
||||
|
||||
/** 待支付汇总:未支付笔数/金额(含审核中),不受列表筛选影响 */
|
||||
public function test_bill_list_summary_aggregates_unpaid(): void
|
||||
{
|
||||
[$store, $user] = $this->makeStoreWithUser();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->makeBill($store, ['total_amount' => '100.00']);
|
||||
$this->makeBill($store, ['total_amount' => '50.50', 'payment_id' => 99]);
|
||||
$this->makeBill($store, ['total_amount' => '200.00', 'status' => BillModel::STATUS_PAID]);
|
||||
|
||||
$response = $this->getJson('/mini/bill?status=1')->assertOk();
|
||||
$this->assertSame(1, $response->json('data.total'), 'status 筛选只影响列表');
|
||||
$this->assertSame(2, $response->json('data.summary.unpaid_count'));
|
||||
$this->assertSame('150.50', $response->json('data.summary.unpaid_amount'));
|
||||
}
|
||||
|
||||
/** 可付款筛选:仅未支付且未锁定到支付记录的账单(合并付款选择页) */
|
||||
public function test_bill_list_payable_filter(): void
|
||||
{
|
||||
[$store, $user] = $this->makeStoreWithUser();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$payable = $this->makeBill($store);
|
||||
$this->makeBill($store, ['payment_id' => 99]);
|
||||
$this->makeBill($store, ['status' => BillModel::STATUS_PAID]);
|
||||
|
||||
$rows = $this->getJson('/mini/bill?payable=1')->assertOk()->json('data.data');
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame($payable->id, $rows[0]['id']);
|
||||
}
|
||||
|
||||
/** 账单日期区间筛选 */
|
||||
public function test_bill_list_filters_by_bill_date_range(): void
|
||||
{
|
||||
[$store, $user] = $this->makeStoreWithUser();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->makeBill($store, ['bill_date' => '2026-08-01']);
|
||||
$this->makeBill($store, ['bill_date' => '2026-08-05']);
|
||||
$this->makeBill($store, ['bill_date' => '2026-08-10']);
|
||||
|
||||
$this->getJson('/mini/bill?start_date=2026-08-03')->assertJsonPath('data.total', 2);
|
||||
$this->getJson('/mini/bill?start_date=2026-08-03&end_date=2026-08-06')->assertJsonPath('data.total', 1);
|
||||
$this->getJson('/mini/bill?end_date=2026-08-06')->assertJsonPath('data.total', 2);
|
||||
}
|
||||
|
||||
/** 门店数据隔离:只能查看本店账单 */
|
||||
public function test_bill_data_isolated_between_stores(): void
|
||||
{
|
||||
[$storeA] = $this->makeStoreWithUser();
|
||||
$billOfA = $this->makeBill($storeA);
|
||||
|
||||
[, $userB] = $this->makeStoreWithUser();
|
||||
$this->actingAsMiniUser($userB);
|
||||
|
||||
$this->getJson('/mini/bill')->assertJsonPath('data.total', 0);
|
||||
$this->getJson("/mini/bill/{$billOfA->id}")->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 详情:格式化账单 + 合并商品明细 + 关联订单 */
|
||||
public function test_bill_detail_outputs_bill_items_and_orders(): void
|
||||
{
|
||||
[$store, $user] = $this->makeStoreWithUser(0);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$bill = $this->makeBill($store);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$order = StoreOrderModel::factory()->create([
|
||||
'store_id' => $store->id,
|
||||
'purchase_id' => $bill->purchase_id,
|
||||
'bill_id' => $bill->id,
|
||||
'total_amount' => '100.00',
|
||||
]);
|
||||
StoreOrderItemModel::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'purchase_id' => $bill->purchase_id,
|
||||
'bill_id' => $bill->id,
|
||||
'store_id' => $store->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name' => '白菜',
|
||||
'price' => '5.00',
|
||||
'quantity' => 20,
|
||||
'amount' => '100.00',
|
||||
]);
|
||||
|
||||
$data = $this->getJson("/mini/bill/{$bill->id}")->assertOk()->json('data');
|
||||
|
||||
$this->assertSame($bill->bill_no, $data['bill']['bill_no']);
|
||||
$this->assertSame('待支付', $data['bill']['pay_state_name']);
|
||||
$this->assertSame('2026-08-10', $data['bill']['settlement_date'], '回款周期 0 天,出账当天应结算');
|
||||
$this->assertSame($bill->purchase->purchase_no, $data['bill']['purchase']['purchase_no']);
|
||||
|
||||
$this->assertCount(1, $data['items']);
|
||||
$this->assertSame('白菜', $data['items'][0]['product_name']);
|
||||
$this->assertSame('5.00', $data['items'][0]['price'], '合并明细单价 = Σ金额 ÷ Σ数量');
|
||||
$this->assertSame(20, $data['items'][0]['quantity']);
|
||||
$this->assertSame('100.00', $data['items'][0]['amount']);
|
||||
|
||||
$this->assertCount(1, $data['orders']);
|
||||
$this->assertSame($order->order_no, $data['orders'][0]['order_no']);
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,68 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
||||
}
|
||||
|
||||
/** 列表输出:状态名 / 可取消标记 / 商品预览(前 3 条)与明细种数 */
|
||||
public function test_order_list_outputs_status_name_and_item_preview(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$items = [];
|
||||
foreach (['白菜', '土豆', '番茄', '黄瓜'] as $name) {
|
||||
$product = ProductModel::factory()->create(['name' => $name, 'status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
$items[] = ['product_id' => $product->id, 'quantity' => 2];
|
||||
}
|
||||
$this->postJson('/mini/order', ['items' => $items])->assertJsonPath('success', true);
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
|
||||
$row = collect($this->getJson('/mini/order')->assertOk()->json('data.data'))->firstWhere('id', $order->id);
|
||||
$this->assertSame('待接单', $row['status_name']);
|
||||
$this->assertTrue($row['can_cancel']);
|
||||
$this->assertSame(4, $row['item_count']);
|
||||
$this->assertCount(3, $row['items'], '预览仅输出前 3 条明细,完整明细走详情接口');
|
||||
$this->assertSame('白菜', $row['items'][0]['product_name']);
|
||||
$this->assertSame(2, $row['items'][0]['quantity']);
|
||||
|
||||
// 已接单后状态名变化且不可取消
|
||||
$order->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
$row = collect($this->getJson('/mini/order')->json('data.data'))->firstWhere('id', $order->id);
|
||||
$this->assertSame('已接单', $row['status_name']);
|
||||
$this->assertFalse($row['can_cancel']);
|
||||
}
|
||||
|
||||
/** 订货日期区间筛选(配合 /order/summary 周期下钻) */
|
||||
public function test_order_list_filters_by_order_date_range(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
foreach ([1, 1, 1] as $qty) {
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
$orders = StoreOrderModel::where('store_id', $store->id)->orderBy('id')->get();
|
||||
$orders[0]->update(['order_date' => '2026-08-01']);
|
||||
$orders[1]->update(['order_date' => '2026-08-05']);
|
||||
$orders[2]->update(['order_date' => '2026-08-10']);
|
||||
|
||||
$this->getJson('/mini/order?start_date=2026-08-03')->assertJsonPath('data.total', 2);
|
||||
$this->getJson('/mini/order?start_date=2026-08-03&end_date=2026-08-06')->assertJsonPath('data.total', 1);
|
||||
$this->getJson('/mini/order?end_date=2026-08-06')->assertJsonPath('data.total', 2);
|
||||
}
|
||||
|
||||
/** 非法筛选参数被拦截(状态枚举 / 分页上限 / 日期区间倒置) */
|
||||
public function test_order_list_rejects_invalid_filters(): void
|
||||
{
|
||||
[, , $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->getJson('/mini/order?status=7')->assertJsonPath('success', false);
|
||||
$this->getJson('/mini/order?pageSize=100')->assertJsonPath('success', false);
|
||||
$this->getJson('/mini/order?start_date=2026-08-10&end_date=2026-08-01')->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00) */
|
||||
private function makeOrderWithStatus(int $status): StoreOrderModel
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import { downloadBlob } from '@/api/common/download.ts';
|
||||
import type { IBillDetail } from '@/domain/iBill.ts';
|
||||
|
||||
/** 账单详情(账单信息 + 合并商品明细 + 关联订单) */
|
||||
@@ -17,3 +18,12 @@ export async function payBill(id: number, data: { paid_at: string; pay_remark?:
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 合并导出账单:勾选账单跨账单按商品合并明细,可按一级分类过滤 */
|
||||
export async function exportBills(ids: number[], categoryId: number) {
|
||||
return downloadBlob(
|
||||
'/recon/bill/export',
|
||||
{ ids: ids.join(','), category_id: categoryId },
|
||||
'门店账单.xlsx'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
@@ -24,8 +25,9 @@ import type {
|
||||
import type { IBill, IBillDetail, IBillGoodsItem, IBillOrder } from '@/domain/iBill.ts';
|
||||
import { BILL_STATUS_MAP } from '@/domain/iBill.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import { getBillDetail, payBill } from '@/api/recon/bill.ts';
|
||||
import { exportBills, getBillDetail, payBill } from '@/api/recon/bill.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { getCategoryTree } from '@/api/product/category.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
@@ -53,8 +55,20 @@ const BillPage: React.FC = () => {
|
||||
const [paySaving, setPaySaving] = useState(false);
|
||||
const [payForm] = Form.useForm<PayFormValues>();
|
||||
|
||||
// 合并导出(勾选账单跨账单按商品合并明细,可按一级分类过滤)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [categoryId, setCategoryId] = useState(0);
|
||||
const [topCategories, setTopCategories] = useState<{ value: number; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
getCategoryTree().then((res) =>
|
||||
setTopCategories(
|
||||
(res.data.data ?? []).map((c) => ({ value: c.id!, label: c.name ?? '' })),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
@@ -93,6 +107,20 @@ const BillPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 提交合并导出:下载失败时 downloadBlob 内部已提示,保留勾选便于重试 */
|
||||
const handleExport = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportBills(selectedRowKeys, categoryId);
|
||||
setExportOpen(false);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 合并商品明细列:品名/包规/单位/单价(加权平均)/数量/重量/金额 */
|
||||
const itemColumns: TableProps<IBillGoodsItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name', width: 160, align: 'center' },
|
||||
@@ -306,6 +334,26 @@ const BillPage: React.FC = () => {
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
|
||||
rowSelection: {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||
preserveSelectedRowKeys: true,
|
||||
},
|
||||
toolBarRender: (dom) => [
|
||||
<AuthButton key="export" auth="recon.bill.export">
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
导出{selectedRowKeys.length > 0 ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
dom.columnSetting,
|
||||
dom.hideBorder,
|
||||
dom.reload,
|
||||
dom.columnHeight,
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -313,7 +361,8 @@ const BillPage: React.FC = () => {
|
||||
<div className="mb-5">
|
||||
<Title level={3}>门店账单</Title>
|
||||
<Text type="secondary">
|
||||
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)。
|
||||
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘);
|
||||
勾选多张账单可合并导出为一个 Excel(明细跨账单按商品合并,可按一级分类过滤)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IBill> {...tableProps} />
|
||||
@@ -415,6 +464,32 @@ const BillPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 合并导出:勾选账单跨账单按商品合并明细,可按一级分类过滤 */}
|
||||
<Modal
|
||||
title="导出账单"
|
||||
open={exportOpen}
|
||||
onCancel={() => setExportOpen(false)}
|
||||
onOk={handleExport}
|
||||
confirmLoading={exporting}
|
||||
okText="导出"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
已选 <Text strong>{selectedRowKeys.length}</Text> 张账单,导出为一个
|
||||
Excel:商品明细跨账单按商品合并,表尾汇总配送费、附加金额与总金额;
|
||||
选择分类后仅导出该一级分类下的商品明细(配送费/附加金额仍全额汇总)。
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="商品分类">
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(value) => setCategoryId(value)}
|
||||
options={[{ value: 0, label: '全部分类' }, ...topCategories]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user