Compare commits

...

43 Commits

Author SHA1 Message Date
xinadmin 3005646179 打包前端 2026-09-07 23:28:54 +08:00
xinadmin edf5e42c38 售后金额上浮 2026-09-07 23:27:56 +08:00
xinadmin 58c733baa2 账单总金额 2026-09-07 23:07:12 +08:00
xinadmin 7de51391e4 批量对账 2026-09-07 22:53:50 +08:00
xinadmin 379aeac2b3 前端打包 2026-09-07 12:25:59 +08:00
xinadmin a636bd31b5 前端打包 2026-09-07 12:21:40 +08:00
xinadmin b784210012 账单样式 2026-09-07 12:21:01 +08:00
xinadmin 51ccaaf11a 账单价格 2026-09-06 23:20:00 +08:00
xinadmin 7a895c143e 对账标记 2026-09-06 22:48:45 +08:00
xinadmin f9eb6f7d32 导出时间 2026-09-06 22:37:46 +08:00
xinadmin f8e81cb830 导出 2026-09-06 22:34:25 +08:00
xinadmin 52f396c12e 截单时间 2026-09-05 19:40:52 +08:00
xinadmin b1bb40a088 排序 2026-09-05 15:17:58 +08:00
xinadmin 12cbed411b 默认重量 2026-09-05 14:37:22 +08:00
xinadmin 364471a086 前端打包 2026-09-05 14:05:16 +08:00
xinadmin 5c7a5829af 导出优化 2026-09-05 14:03:23 +08:00
xinadmin 17c1281073 回调优化 2026-09-04 23:30:13 +08:00
xinadmin 968054b9ff 排序 2026-09-04 23:05:31 +08:00
xinadmin c33247f214 排序 2026-09-04 22:42:50 +08:00
xinadmin 1e25459676 排序 2026-09-04 22:10:28 +08:00
xinadmin cf1d3a6229 微信公众号支付 2026-09-04 21:22:46 +08:00
xinadmin 52e339cdd3 应用设置优化 2026-09-04 20:37:34 +08:00
xinadmin c986c86ed5 导出样式 2026-09-03 22:00:58 +08:00
xinadmin 1370926780 小程序支付 2026-09-03 20:36:09 +08:00
xinadmin d190bdaade 支付优化 2026-09-01 12:47:02 +08:00
xinadmin 95ea0714ad 测试 2026-09-01 11:07:00 +08:00
xinadmin 9c59f3bc99 推荐商品 2026-08-31 22:21:59 +08:00
xinadmin 6c809d893b 采购单修改 2026-08-31 21:45:50 +08:00
xinadmin f8cc40a609 样式修改 2026-08-31 20:48:11 +08:00
xinadmin d0ce897a0f 售后金额 2026-08-29 13:54:06 +08:00
xinadmin c3ec416a77 打包前端 2026-08-27 21:17:33 +08:00
xinadmin c35dac0055 在线支付 2026-08-27 21:17:02 +08:00
xinadmin 65eb8ef594 采购单样式修改 2026-08-27 18:57:16 +08:00
xinadmin f3e190f460 购物车悬浮 2026-08-27 18:13:43 +08:00
xinadmin 52dc6efadf 修复BUG 2026-08-27 14:20:09 +08:00
xinadmin 513cc568a9 打包前端 2026-08-23 14:27:24 +08:00
xinadmin 5d58075644 批量删除 2026-08-23 14:26:37 +08:00
xinadmin 01e67e02b5 运营报表 2026-08-21 12:05:31 +08:00
xinadmin e5f849ef4d 前端构建 2026-08-20 16:54:34 +08:00
xinadmin 1dfa084508 数据库迁移优化 2026-08-20 16:52:58 +08:00
xinadmin 4fe4e985f6 布局适配 2026-08-20 16:48:04 +08:00
xinadmin 22237a47e7 添加市场 2026-08-20 15:39:39 +08:00
xinadmin cd7007bc49 商品导入导出 2026-08-20 14:45:06 +08:00
278 changed files with 9300 additions and 631 deletions
+33
View File
@@ -46,3 +46,36 @@ MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME=
# 微信小程序(code2session 换取 openid,在线支付必需;也可在后台 系统设置→微信应用配置 维护,后台配置优先)
WECHAT_MINI_APPID=
WECHAT_MINI_SECRET=
# 微信公众号(网页授权换 openid,公众号 H5 JSAPI 支付用;后台 系统设置→微信应用配置 优先)
WECHAT_MP_APPID=
WECHAT_MP_SECRET=
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
PAY_ONLINE_CHANNEL=wangpu
# 旺铺支付网关(也可在后台 系统设置→旺铺支付 中维护,后台配置优先)
WANGPU_BASE_URL=
WANGPU_ORGANIZ_NO=
WANGPU_MER_NO=
WANGPU_MER_CODE=
WANGPU_TERM_CODE=
WANGPU_PUBLIC_KEY=
WANGPU_PRIVATE_KEY=
WANGPU_SUB_APPID=
WANGPU_PAYWAY_CODE=WECHAT_MINI
# 公众号场景支付方式代码(留空回退 WANGPU_PAYWAY_CODE
WANGPU_MP_PAYWAY_CODE=
# 微信官方支付 APIv3(也可在后台 系统设置→微信官方支付 中维护,后台配置优先)
WXPAY_MCH_ID=
WXPAY_APPID=
WXPAY_PRIVATE_KEY=
WXPAY_CERTIFICATE=
WXPAY_SECRET_KEY=
WXPAY_PLATFORM_CERT=
WXPAY_PLATFORM_SERIAL=
File diff suppressed because one or more lines are too long
+89 -6
View File
@@ -10,14 +10,26 @@ use App\Services\BillDetailService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
* 表尾汇总商品金额/配送费/附加金额/总金额
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
class BillExport implements FromCollection, WithStyles
{
/** 数量列填充色(浅黄) */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 金额列填充色(浅红) */
private const string AMOUNT_FILL = 'FFFFCCCC';
/** 金额格式:¥ + 两位小数 */
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand */
@@ -26,9 +38,15 @@ class BillExport implements FromCollection, WithStyles
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
private array $mergeRows = [];
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */
private array $fillCells = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/**
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store
* @param int $categoryId 一级分类ID0=全部)
@@ -76,7 +94,7 @@ class BillExport implements FromCollection, WithStyles
->all();
$rows[] = ['账单号:' . $billNoText];
$this->specialRows[++$rowIndex] = 'scope';
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
@@ -104,6 +122,8 @@ class BillExport implements FromCollection, WithStyles
(float) $item['amount'],
];
$rowIndex++;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $item['quantity']];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $item['amount']];
$totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
@@ -112,37 +132,51 @@ class BillExport implements FromCollection, WithStyles
// 商品合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
// 配送费/附加金额/总金额(账单级费用全额汇总,不受分类过滤影响)
// 配送费/附加金额/售后金额/总金额(账单级费用全额汇总,不受分类过滤影响)
$deliveryTotal = '0';
$addedTotal = '0';
$afterSaleTotal = '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);
$afterSaleTotal = bcadd($afterSaleTotal, (string) $bill->after_sale, 2);
$boxNum += (int) $bill->box_num;
$trayNum += (int) $bill->tray_num;
}
$grandTotal = bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2);
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额', '', '', '', '', '', '', (float) $grandTotal];
$rows[] = ['售后金额合计(可正负', '', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额类列货币格式;
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
@@ -162,6 +196,43 @@ class BillExport implements FromCollection, WithStyles
$sheet->mergeCells('A' . $row . ':G' . $row);
}
// 全部单元格水平/垂直居中(合计行标签保持左对齐)
$sheet->getStyle('A1:H' . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
foreach ($this->mergeRows as $row) {
$sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
}
// 表格区域(列头 → 末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':H' . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额类列(单价/金额)货币格式:¥ + 两位小数
foreach (['E', 'H'] as $column) {
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
}
// 列头固定填色:数量列(F)浅黄、金额列(H)浅红
$this->fillCell($sheet, 'F' . $this->headerRow, self::QUANTITY_FILL);
$this->fillCell($sheet, 'H' . $this->headerRow, self::AMOUNT_FILL);
// 数据区条件填色:非 0 填色,0 无背景(金额含负值)
foreach ($this->fillCells as $cell) {
if ((float) $cell['value'] !== 0.0) {
$this->fillCell(
$sheet,
$cell['column'] . $cell['row'],
$cell['column'] === 'F' ? self::QUANTITY_FILL : self::AMOUNT_FILL
);
}
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
@@ -178,6 +249,18 @@ class BillExport implements FromCollection, WithStyles
return $styles;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
/**
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
* 仅保留根分类为所选分类的行
+90 -3
View File
@@ -8,6 +8,10 @@ use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
@@ -20,14 +24,32 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
*/
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
{
/** 门店列填充色(浅蓝) */
private const string STORE_FILL = 'FFDDEBF7';
/** 合计列填充色(浅红) */
private const string AMOUNT_FILL = 'FFFFCCCC';
/** 金额格式:¥ + 两位小数 */
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total */
private array $specialRows = [];
/** @var array<int, array{stores: array<int, float>, total: float}> 日期行索引 => 填色判断数据 */
private array $rowData = [];
/** @var array{stores: array<int, float>, total: float} 合计行填色判断数据 */
private array $summaryTotals = ['stores' => [], 'total' => 0.0];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/**
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
@@ -70,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$this->specialRows[++$rowIndex] = 'title';
// 范围行:门店 / 导出时间
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
@@ -90,16 +112,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$dateKey = $date->format('Y-m-d');
$row = [$dateKey];
$rowTotal = '0';
$storeValues = [];
foreach ($storeIds as $storeId) {
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
$row[] = (float) $amount;
$storeValues[$storeId] = (float) $amount;
$rowTotal = bcadd($rowTotal, $amount, 2);
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
}
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
$row[] = (float) $rowTotal;
$rows[] = $row;
$rowIndex++;
$this->rowData[++$rowIndex] = ['stores' => $storeValues, 'total' => (float) $rowTotal];
}
// 合计行:各门店列合计 + 总计
@@ -110,12 +134,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$totalRow[] = (float) $grandTotal;
$rows[] = $totalRow;
$this->specialRows[++$rowIndex] = 'total';
$this->lastRow = $rowIndex;
$this->summaryTotals = [
'stores' => array_map(floatval(...), $columnTotals),
'total' => (float) $grandTotal,
];
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,冻结列头行
* 标题/列头/合计行加粗;全表居中 + 全边框;金额区货币格式;
* 门店列(浅蓝)/合计列(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头行
*/
public function styles(Worksheet $sheet): array
{
@@ -128,6 +158,51 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
}
$storeIds = array_map(intval(...), array_keys($this->storeNames));
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
// 全部单元格水平/垂直居中
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 表格区域(列头 → 合计行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额区(门店列 + 合计列)货币格式:¥ + 两位小数
$sheet->getStyle('B' . ($this->headerRow + 1) . ':' . $lastColumn . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
// 门店列(B 起):非 0 浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) {
$column = Coordinate::stringFromColumnIndex(2 + $i);
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
foreach ($this->rowData as $row => $data) {
if (($data['stores'][$storeId] ?? 0.0) !== 0.0) {
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
}
}
if (($this->summaryTotals['stores'][$storeId] ?? 0.0) !== 0.0) {
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
}
}
// 合计列(末列):非 0 浅红,0 无背景,列头固定浅红
$this->fillCell($sheet, $lastColumn . $this->headerRow, self::AMOUNT_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['total'] !== 0.0) {
$this->fillCell($sheet, $lastColumn . $row, self::AMOUNT_FILL);
}
}
if ($this->summaryTotals['total'] !== 0.0) {
$this->fillCell($sheet, $lastColumn . $this->lastRow, self::AMOUNT_FILL);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
@@ -142,4 +217,16 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
return $styles;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Exports;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\SupplierModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 商品档案导出:列格式与导入模板完全一致(首行即列头,导出文件修改后可直接重新导入)。
* 分类列输出「父分类/子分类」完整路径;状态列输出中文。
* template 模式仅输出列头 + 一行示例数据(首次导入前下载参考)。
*/
class ProductExport implements FromCollection, WithStrictNullComparison, WithStyles
{
/** 列头(与 ProductImport 的解析顺序一一对应,改动需同步) */
public const array HEADERS = ['品名', '分类', '供应商', '市场', '规格/包规', '单位', '成本价', '排序', '库存', '保质期', '状态', '备注'];
private ?Collection $rows = null;
/**
* @param int $categoryId 末级分类筛选(0=全部商品)
* @param bool $template 仅输出列头 + 示例行(导入模板)
*/
public function __construct(
private readonly int $categoryId = 0,
private readonly bool $template = false,
) {
}
/**
* 行结构:第 1 行列头,其后每行一件商品
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$rows = [self::HEADERS];
if ($this->template) {
$rows[] = ['西红柿', '蔬菜/茄果类', '示例供应商', '新发地', '10斤/箱', '斤', 2.5, 0, 100, 3, '上架', '示例行,导入前请删除'];
return $this->rows = collect($rows);
}
$products = ProductModel::query()
->when($this->categoryId > 0, fn ($query) => $query->where('category_id', $this->categoryId))
->orderBy('sort')
->orderBy('id')
->get()
->makeVisible('cost_price');
$categoryPaths = $this->categoryPaths($products->pluck('category_id')->unique()->all());
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id');
foreach ($products as $product) {
$rows[] = [
$product->name,
$categoryPaths[(int) $product->category_id] ?? '',
$supplierNames[(int) $product->supplier_id] ?? '',
$product->market,
$product->spec,
$product->unit,
(float) $product->cost_price,
(int) $product->sort,
(int) $product->stock,
(int) $product->shelf_life,
(int) $product->status === ProductModel::STATUS_ON ? '上架' : '下架',
$product->remark,
];
}
return $this->rows = collect($rows);
}
/**
* 列头加粗、冻结首行、设置列宽
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A2');
$widths = [20, 16, 14, 12, 14, 8, 10, 8, 8, 8, 8, 24];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
return [
1 => ['font' => ['bold' => true]],
];
}
/**
* 分类ID => 「父分类/子分类」完整路径(沿 parent_id 上溯拼接)
*
* @param array<int, int> $categoryIds
* @return array<int, string>
*/
private function categoryPaths(array $categoryIds): array
{
$categories = ProductCategoryModel::all()->keyBy('id');
$paths = [];
foreach ($categoryIds as $categoryId) {
$names = [];
$cursor = (int) $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20) {
$category = $categories->get($cursor);
if ($category === null) {
break;
}
array_unshift($names, $category->name);
$cursor = (int) $category->parent_id;
}
$paths[(int) $categoryId] = implode('/', $names);
}
return $paths;
}
}
+136 -28
View File
@@ -14,22 +14,42 @@ use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;门店列整列填充突出颜色
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色;
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
*/
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{
/** 门店列填充色(浅橙,突出显示 */
private const string STORE_FILL = 'FFFFF7E6';
/** 数量列填充色(浅 */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 门店数量列填充色(浅蓝) */
private const string STORE_FILL = 'FFDDEBF7';
/** 金额列填充色(浅红) */
private const string AMOUNT_FILL = 'FFFFCCCC';
/** 金额格式:¥ + 两位小数 */
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary */
private array $specialRows = [];
/** @var array<int, array{quantity: float, amount: float, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = [];
/** @var array{quantity: float, amount: float, stores: array<int, int>} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0.0, 'amount' => 0.0, 'stores' => []];
/** 列头所在行索引 */
private int $headerRow = 1;
@@ -96,10 +116,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
];
}
// 导出范围:系统全部上架商品 本采购单有订货的商品(含已删/下架
// 导出范围:系统全部未删除商品(含下架) 本采购单有订货的商品(含已删)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->where('status', ProductModel::STATUS_ON)
->whereNull('deleted_at')
->orWhereIn('id', array_keys($itemGroups))
->get()
->keyBy('id');
@@ -126,13 +146,14 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
'category' => $categoryNames[(int) $productId] ?? '',
'product_name' => (string) ($snapshot->product_name ?? $product->name),
'supplier' => $supplierNames[$rowSupplierId] ?? '',
'market' => (string) ($product->market ?? ''),
'product_spec' => $spec,
'unit' => (string) ($snapshot->unit ?? $product->unit),
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
// 参考零售价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空
// 价 = 加权平均售价 ÷ 包规数值(无订货时按成本价换算,保证始终有单价
'retail_price' => $group !== null && (float) $quantity > 0
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
: null,
: $this->unitRefPrice((float) ($snapshot->cost_price ?? $product->cost_price), $spec),
'quantity' => (float) $quantity,
'weight' => (float) ($group['weight'] ?? '0'),
'amount' => (float) $amount,
@@ -163,7 +184,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
if ($this->supplierId > 0) {
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
}
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
@@ -172,7 +193,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
// 列头
$rows[] = array_merge(
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'],
['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '价', '数量', '实际称重', '金额'],
array_values($this->storeNames),
);
$this->specialRows[++$rowIndex] = 'header';
@@ -184,20 +205,28 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$totalAmount = '0';
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
foreach (array_values($items) as $sort => $item) {
$this->rowData[++$rowIndex] = [
'quantity' => $item['quantity'],
'amount' => $item['amount'],
'store_quantities' => $item['store_quantities'],
];
$rows[] = array_merge([
$sort + 1,
$item['category'],
$item['product_name'],
$item['supplier'],
$item['market'],
$item['product_spec'],
$item['unit'],
$item['cost_price'],
$item['retail_price'] !== null ? $item['retail_price'] : '',
$item['retail_price'],
$item['quantity'],
$item['weight'],
$item['amount'],
], array_values($item['store_quantities']));
$rowIndex++;
], array_map(
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
array_values($item['store_quantities']),
));
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
@@ -206,40 +235,107 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
}
}
// 合计行(行合计 + 门店列合计)
// 合计行(行合计 + 门店列合计;门店无数据显示空白
$rows[] = array_merge(
['', '', '合计', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_values($storeTotals),
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
$this->summaryTotals = [
'quantity' => (float) $totalQuantity,
'amount' => (float) $totalAmount,
'stores' => $storeTotals,
];
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计加粗,门店列整列填充突出颜色,冻结列头
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
* 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 10, 20, 12, 12, 8, 10, 12, 10, 12, 12];
// 市场/数量列宽减半
$widths = [6, 10, 20, 12, 6, 12, 8, 10, 12, 5, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
// 门店列整列(列头 → 合计行)填充突出颜色
$storeCount = count($this->storeNames);
for ($i = 0; $i < $storeCount; $i++) {
$column = Coordinate::stringFromColumnIndex(12 + $i);
$sheet->getColumnDimension($column)->setWidth(12);
$sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow)
->getFill()
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
->getStartColor()
->setARGB(self::STORE_FILL);
$storeIds = array_keys($this->storeNames);
$storeCount = count($storeIds);
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(6);
}
// 默认隐藏列:序号/分类/包规/单位/成本/单价/实际称重/金额(数据照常导出,Excel 中可取消隐藏)
foreach ([1, 2, 6, 7, 8, 9, 11, 12] as $hiddenIndex) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($hiddenIndex))->setVisible(false);
}
// 全部单元格水平/垂直居中
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 列头自动换行(市场/数量/门店列较窄,表头文字折行显示)
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
->getAlignment()
->setWrapText(true);
// 表格区域(列头 → 合计行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额类列(成本/单价/金额)货币格式:¥ + 两位小数
foreach (['H', 'I', 'L'] as $column) {
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
}
// 数量列(J):有数据浅黄,0 无背景,列头固定浅黄
$this->fillCell($sheet, 'J' . $this->headerRow, self::QUANTITY_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['quantity'] > 0) {
$this->fillCell($sheet, 'J' . $row, self::QUANTITY_FILL);
}
}
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'J' . $this->lastRow, self::QUANTITY_FILL);
}
// 金额列(L):有数据浅红,0 无背景,列头固定浅红
$this->fillCell($sheet, 'L' . $this->headerRow, self::AMOUNT_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['amount'] > 0) {
$this->fillCell($sheet, 'L' . $row, self::AMOUNT_FILL);
}
}
if ($this->summaryTotals['amount'] > 0) {
$this->fillCell($sheet, 'L' . $this->lastRow, self::AMOUNT_FILL);
}
// 门店数量列:有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) {
$column = Coordinate::stringFromColumnIndex(13 + $i);
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
foreach ($this->rowData as $row => $data) {
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
}
}
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
}
}
$styles = [];
@@ -257,6 +353,18 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
return $styles;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
/**
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考)
*/
+86 -5
View File
@@ -10,6 +10,9 @@ use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
@@ -17,14 +20,32 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
*/
class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
{
/** 数量列填充色(浅黄) */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 金额列填充色(浅红) */
private const string AMOUNT_FILL = 'FFFFCCCC';
/** 金额格式:¥ + 两位小数 */
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
private array $specialRows = [];
/** @var array<int, array{quantity: int, amount: float}> 明细行索引 => 填色判断数据 */
private array $rowData = [];
/** @var array{quantity: int, amount: float} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0, 'amount' => 0.0];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly StoreModel $store,
@@ -55,7 +76,7 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
$rowIndex++;
// 列头
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
$rows[] = ['序号', '品名', '市场', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
@@ -64,9 +85,14 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
$totalWeight = '0';
$totalAmount = '0';
foreach (array_values($items) as $sort => $item) {
$this->rowData[++$rowIndex] = [
'quantity' => (int) $item['quantity'],
'amount' => (float) $item['amount'],
];
$rows[] = [
$sort + 1,
$item['product_name'],
$item['market'],
$item['product_spec'],
$item['unit'],
(float) $item['price'],
@@ -74,15 +100,16 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
(float) $item['weight'],
(float) $item['amount'],
];
$rowIndex++;
$totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
}
// 合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
$this->summaryTotals = ['quantity' => $totalQuantity, 'amount' => (float) $totalAmount];
return $this->rows = collect($rows);
}
@@ -93,19 +120,61 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
}
/**
* 标题/列头/合计加粗,冻结列头
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
* 数量(浅黄)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
->setWidth($width);
}
// 全部单元格水平/垂直居中
$sheet->getStyle('A1:I' . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 表格区域(列头 → 合计行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':I' . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额类列(单价/预计金额)货币格式:¥ + 两位小数
foreach (['F', 'I'] as $column) {
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
}
// 数量列(G):有数据浅黄,0 无背景,列头固定浅黄
$this->fillCell($sheet, 'G' . $this->headerRow, self::QUANTITY_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['quantity'] > 0) {
$this->fillCell($sheet, 'G' . $row, self::QUANTITY_FILL);
}
}
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'G' . $this->lastRow, self::QUANTITY_FILL);
}
// 金额列(I):有数据浅红,0 无背景,列头固定浅红
$this->fillCell($sheet, 'I' . $this->headerRow, self::AMOUNT_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['amount'] > 0) {
$this->fillCell($sheet, 'I' . $row, self::AMOUNT_FILL);
}
}
if ($this->summaryTotals['amount'] > 0) {
$this->fillCell($sheet, 'I' . $this->lastRow, self::AMOUNT_FILL);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
@@ -120,4 +189,16 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
return $styles;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
}
+18 -7
View File
@@ -6,11 +6,13 @@ use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel;
use App\Services\PurchaseItemService;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
/**
* 供应商采购明细导出:多供应商合并为一个 XLSX(每供应商一个工作表,工作表名=供应商名称);
* 构造传入 supplierId 时仅导出该供应商(单工作表)
* 供应商采购明细导出:多供应商合并为一个 XLSX,按「供应商 × 市场」拆分工作表
* (工作表名=供应商·市场,市场取商品档案,未设置市场归入「未设置」);
* 构造传入 supplierId 时仅导出该供应商
*/
class PurchaseSupplierExport implements WithMultipleSheets
{
@@ -51,14 +53,23 @@ class PurchaseSupplierExport implements WithMultipleSheets
->orderBy('id')
->get(['id', 'name']);
$service = app(PurchaseItemService::class);
$usedNames = [];
$sheets = [];
foreach ($suppliers as $supplier) {
$sheets[] = new PurchaseSupplierSheet(
$this->purchase,
$supplier,
SheetName::make((string) $supplier->name, (int) $supplier->id, $usedNames),
);
// 按市场拆分工作表
$matrix = $service->supplierMarketMatrix($this->purchase->id, (int) $supplier->id);
foreach ($matrix['markets'] as $market => $rows) {
$marketLabel = $market === '' ? '未设置' : $market;
$sheets[] = new PurchaseSupplierSheet(
$this->purchase,
$supplier,
$marketLabel,
$rows,
$matrix['stores'],
SheetName::make($supplier->name . '·' . $marketLabel, (int) $supplier->id, $usedNames),
);
}
}
return $sheets;
+128 -35
View File
@@ -4,37 +4,64 @@ namespace App\Exports;
use App\Models\PurchaseOrderModel;
use App\Models\SupplierModel;
use App\Services\PurchaseItemService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 供应商采购明细导出 · 单供应商工作表(成本口径:金额=Σ数量×成本价)
* 供应商采购明细导出 · 单市场工作表
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色)
*/
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
{
/** 汇总列填充色(浅黄) */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 门店数量列填充色(浅蓝) */
private const string STORE_FILL = 'FFDDEBF7';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header */
private array $specialRows = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = [];
/**
* @param PurchaseOrderModel $purchase 采购单
* @param SupplierModel $supplier 供应商
* @param string $marketLabel 市场名(空市场已转为「未设置」)
* @param array<int, array<string, mixed>> $productRows 商品行(品名/汇总数量/各门店数量)
* @param array<int, string> $stores 门店列(门店ID => 名称)
* @param string $sheetTitle 工作表名
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly SupplierModel $supplier,
private readonly string $marketLabel,
private readonly array $productRows,
private readonly array $stores,
private readonly string $sheetTitle,
) {
}
/**
* 导出行:标题/空行/列头/明细/合计
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
*/
public function collection(): Collection
{
@@ -42,48 +69,49 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
return $this->rows;
}
$items = app(PurchaseItemService::class)->supplierRows($this->purchase->id, $this->supplier->id);
$storeIds = array_map('intval', array_keys($this->stores));
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = [$this->supplier->name . ' · 采购单 ' . $this->purchase->purchase_no];
// 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
$rows[] = [
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
'',
'',
trim((string) $this->purchase->remark),
];
$this->specialRows[++$rowIndex] = 'title';
// 空行
// 空行(被标题/备注合并区域覆盖)
$rows[] = [''];
$rowIndex++;
// 列头
$rows[] = ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额'];
// 列头:品名、汇总、市场、各门店明细
$rows[] = array_merge(['品名', '汇总', '市场'], array_values($this->stores));
$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['cost_price'],
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
// 明细行0 数量的门店格留空不填色)
foreach ($this->productRows as $productRow) {
$line = [
$productRow['product_name'],
(int) $productRow['quantity'],
$this->marketLabel,
];
$storeQuantities = [];
foreach ($storeIds as $storeId) {
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
$line[] = $quantity > 0 ? $quantity : '';
$storeQuantities[$storeId] = $quantity;
}
$rows[] = $line;
$this->rowData[++$rowIndex] = [
'quantity' => (int) $productRow['quantity'],
'store_quantities' => $storeQuantities,
];
$rowIndex++;
$totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
}
// 合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
@@ -94,23 +122,73 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
/**
* 标题/列头/合计加粗,冻结列头
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行;
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
// 标题合并前三列占两行(A1:C2),备注合并六列两行(D1:I2)
$sheet->mergeCells('A1:C2');
$sheet->mergeCells('D1:I2');
$widths = [24, 10, 6];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$storeIds = array_map('intval', array_keys($this->stores));
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
}
// 全部单元格水平/垂直居中
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 列头行自动换行(门店列窄,门店名换行显示)
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
->getAlignment()
->setWrapText(true);
// 备注合并单元格自动换行(长备注多行显示)
$sheet->getStyle('D1')->getAlignment()->setWrapText(true);
// 表格区域(列头 → 数据末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 汇总列(B):有数据浅黄,0 无背景,列头固定浅黄
$this->fillCell($sheet, 'B' . $this->headerRow, self::QUANTITY_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['quantity'] > 0) {
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
}
}
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) {
$column = Coordinate::stringFromColumnIndex(4 + $i);
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
foreach ($this->rowData as $row => $data) {
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
}
}
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
'header' => ['font' => ['bold' => true]],
default => [],
};
if ($style !== []) {
@@ -118,6 +196,21 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
}
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
return $styles;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Client;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Client\SpecialBatchAddRequest;
use App\Http\Requests\Client\SpecialFormRequest;
use App\Models\HomeSpecialModel;
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('/client/special', 'client.special')]
class SpecialController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
/** 推荐列表(默认按排序升序;keyword 按商品名模糊搜索) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = $this->buildSearch($params, HomeSpecialModel::query()->with('product'));
$keyword = trim((string) ($params['keyword'] ?? ''));
if ($keyword !== '') {
$query->whereHas('product', static function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%');
});
}
$data = $query->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 批量添加推荐商品(已在推荐中的自动跳过) */
#[PostRoute('/batch', 'create')]
public function batchAdd(SpecialBatchAddRequest $request): JsonResponse
{
$productIds = $request->validated()['product_ids'];
$exists = HomeSpecialModel::whereIn('product_id', $productIds)->pluck('product_id')->all();
$newIds = array_values(array_diff($productIds, $exists));
foreach ($newIds as $productId) {
HomeSpecialModel::create([
'product_id' => $productId,
'sort' => 0,
'status' => HomeSpecialModel::STATUS_NORMAL,
]);
}
return $this->success([
'added' => count($newIds),
'skipped' => count($productIds) - count($newIds),
]);
}
/** 编辑推荐(仅排序与状态) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, SpecialFormRequest $request): JsonResponse
{
$model = HomeSpecialModel::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 = HomeSpecialModel::find($id);
if (empty($model)) {
throw new RepositoryException('推荐不存在');
}
$model->delete();
return $this->success();
}
/** 批量删除推荐 */
#[DeleteRoute('/batch', 'delete')]
public function batchDelete(Request $request): JsonResponse
{
$data = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|distinct',
], [
'ids.required' => '请选择要删除的推荐',
'ids.min' => '请选择要删除的推荐',
'ids.*.integer' => '推荐 ID 格式错误',
'ids.*.distinct' => '存在重复的推荐',
]);
HomeSpecialModel::whereIn('id', $data['ids'])->delete();
return $this->success();
}
}
@@ -174,6 +174,7 @@ class BillController extends BaseMiniController
'box_price' => $bill->box_price,
'tray_price' => $bill->tray_price,
'added_amount' => $bill->added_amount,
'after_sale' => $bill->after_sale,
'total_amount' => $bill->total_amount,
'status' => $bill->status,
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
@@ -7,6 +7,7 @@ use App\Http\Requests\Mini\MiniCartRequest;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -144,6 +145,7 @@ class CartController extends BaseMiniController
'name' => $product->name ?? '',
'spec' => $product->spec ?? '',
'unit' => $product->unit ?? '',
'price_unit' => $product->price_unit ?? '',
'image' => $firstFile?->file_url ?? '',
'price' => $price,
'quantity' => $quantity,
@@ -207,6 +209,15 @@ class CartController extends BaseMiniController
return $this->success([], '已删除');
}
/**
* 购物车悬浮球汇总(轻量接口):种数/总数量/总金额,供任意页面刷新右下角悬浮球
*/
#[GetRoute('/cart/summary', authorize: true)]
public function summary(Request $request): JsonResponse
{
return $this->success(app(CartService::class)->summary($this->currentStore($request)));
}
/**
* 清空购物车(仅当前用户)
*/
+5 -1
View File
@@ -5,19 +5,22 @@ namespace App\Http\Controllers\Mini;
use App\Models\HomeBannerModel;
use App\Models\HomeNavModel;
use App\Models\HomePromoModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序首页配置(轮播图 + 宫格导航 + 促销推荐卡片,仅返回启用项)
* 登录门店附加 cart 购物车悬浮球汇总(未登录返回零值结构)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class HomeController extends BaseMiniController
{
/** 首页配置聚合:banners / navs / promos,按 sort 升序 */
#[GetRoute('/home', authorize: false)]
public function index(): JsonResponse
public function index(Request $request): JsonResponse
{
$banners = HomeBannerModel::query()
->where('status', HomeBannerModel::STATUS_NORMAL)
@@ -41,6 +44,7 @@ class HomeController extends BaseMiniController
'banners' => $banners,
'navs' => $navs,
'promos' => $promos,
'cart' => app(CartService::class)->summary($this->optionalStore($request)),
]);
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\PaymentModel;
use App\Services\OnlinePaymentService;
use App\Services\WangpuPayService;
use App\Services\WechatPayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Throwable;
/**
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5)
*
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OnlinePaymentController extends BaseMiniController
{
public function __construct(
protected OnlinePaymentService $onlinePayment,
protected WangpuPayService $wangpu,
protected WechatPayService $wxpay,
) {}
/**
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
*
* scene=mini(默认):小程序 wx.login code 换 openidwx.requestPayment 调起;
* scene=mp:公众号 H5 网页授权 code 换 openidWeixinJSBridge getBrandWCPayRequest 调起。
* @throws Throwable
*/
#[PostRoute('/payment/online', authorize: true)]
public function create(Request $request): JsonResponse
{
$data = $request->validate([
'bill_ids' => 'required|array|min:1',
'bill_ids.*' => 'integer|distinct',
'code' => 'required|string|max:64',
'scene' => 'nullable|string|in:mini,mp',
'remark' => 'nullable|string|max:255',
], [
'bill_ids.required' => '请选择要付款的账单',
'bill_ids.min' => '请选择要付款的账单',
'code.required' => '微信登录/授权凭证缺失,请重新进入',
'scene.in' => '支付场景不正确',
'remark.max' => '备注超过最大长度',
]);
$store = $this->currentStore($request);
[$payment, $payParams] = $this->onlinePayment->create(
$store,
array_map('intval', $data['bill_ids']),
(string) $data['code'],
(string) ($data['remark'] ?? ''),
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
);
return $this->success([
'id' => $payment->id,
'payment_no' => $payment->payment_no,
'amount' => $payment->amount,
'pay_params' => $payParams,
], '下单成功,请调起支付');
}
/**
* 查询支付结果:网关回调可能延迟/丢失,小程序完成支付后主动调用同步结账
* @throws Throwable
*/
#[GetRoute('/payment/online/{paymentNo}/query', authorize: true)]
public function query(string $paymentNo, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$payment = PaymentModel::query()
->where('store_id', $store->id)
->where('payment_no', $paymentNo)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
$result = $this->onlinePayment->queryAndSettle($payment);
$payment = $result['payment'];
return $this->success([
'payment_no' => $payment->payment_no,
'status' => $payment->status,
'status_name' => PaymentModel::ONLINE_STATUS_NAMES[$payment->status] ?? '待支付',
'paid_at' => $payment->paid_at,
'trade_no' => $payment->trade_no,
], $payment->status === PaymentModel::STATUS_APPROVED ? '支付成功' : '支付结果确认中');
}
/**
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
*
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5,
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/notify', authorize: false)]
public function notify(Request $request): JsonResponse
{
try {
$params = $this->wangpu->verifyNotify($request->all());
} catch (Throwable $e) {
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文验签失败');
}
try {
$this->onlinePayment->settleByNotify($params);
} catch (Throwable $e) {
Log::error('旺铺支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
return $this->notifyAck('01', $e->getMessage());
}
return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功');
}
/**
* 微信支付结果后台通知(公开路由,验签 + AES-GCM 解密后幂等结账)
*
* 应答 {"code":"SUCCESS","message":"成功"} 视为通知成功,否则微信按阶梯间隔重推;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/wechat-notify', authorize: false)]
public function wechatNotify(Request $request): JsonResponse
{
try {
$params = $this->wxpay->decryptNotify($request);
} catch (Throwable $e) {
Log::warning('微信支付通知验签/解密失败', ['error' => $e->getMessage()]);
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, '报文验签解密失败');
}
try {
$this->onlinePayment->settleByWechatNotify($params);
} catch (Throwable $e) {
Log::error('微信支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, $e->getMessage());
}
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_OK, '成功');
}
/**
* 微信通知应答报文(微信约定格式)
*/
protected function wechatNotifyAck(string $code, string $msg): JsonResponse
{
return response()->json([
'code' => $code,
'message' => mb_substr($msg, 0, 64),
], $code === WechatPayService::NOTIFY_ACK_OK ? 200 : 500);
}
/**
* 通知应答报文(网关约定格式,timestampyyyyMMddHHmmssSSS
*/
protected function notifyAck(string $code, string $msg): JsonResponse
{
return response()->json([
'code' => $code,
'msg' => mb_substr($msg, 0, 100),
'timestamp' => now()->format('YmdHisv'),
]);
}
}
+80 -2
View File
@@ -4,12 +4,15 @@ namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Mini\MiniOrderRequest;
use App\Models\BillModel;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillNumberService;
use App\Services\ItemImageResolver;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -31,11 +34,35 @@ class OrderController extends BaseMiniController
public function store(MiniOrderRequest $request): JsonResponse
{
$store = $this->currentStore($request);
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
$this->assertWithinOrderTimeWindow();
$level = $store->level_id > 0 ? $store->level : null;
if ($level === null) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
}
// 回款周期校验:存在逾期未回款账单(未支付即拦截,含审核中)时禁止下单
// 周期 0 天=立即结清:任何未回款账单(含当天)都拦截;周期 N≥1:账单日 + N 天 < 今天(超过回款周期)才拦截
$cycleDays = (int) $store->payment_cycle_days;
$overdueQuery = BillModel::query()
->where('store_id', $store->id)
->where('status', BillModel::STATUS_UNPAID);
if ($cycleDays > 0) {
$overdueQuery->whereDate('bill_date', '<', now()->subDays($cycleDays)->toDateString());
}
$overdue = $overdueQuery
->selectRaw('COUNT(*) as aggregate_count, COALESCE(SUM(total_amount), 0) as aggregate_amount')
->first();
if ((int) $overdue->aggregate_count > 0) {
$amountText = bcadd((string) $overdue->aggregate_amount, '0', 2);
throw new RepositoryException($cycleDays > 0
? '您有 ' . (int) $overdue->aggregate_count . ' 笔账单已超过回款周期未回款(合计 ¥' . $amountText . '),请先结清后再下单'
: '您有 ' . (int) $overdue->aggregate_count . ' 笔账单未回款(合计 ¥' . $amountText . '),回款周期为当日结清,请先结清后再下单'
);
}
$items = $request->validated('items');
$remark = (string) ($request->validated('remark') ?? '');
@@ -50,6 +77,7 @@ class OrderController extends BaseMiniController
$totalQuantity = '0';
$totalAmount = '0';
$totalWeight = '0';
$now = now();
$rows = [];
foreach ($items as $row) {
@@ -63,8 +91,11 @@ class OrderController extends BaseMiniController
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2);
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2);
$totalWeight = bcadd($totalWeight, $weight, 3);
$rows[] = [
'store_id' => $store->id,
@@ -75,11 +106,12 @@ class OrderController extends BaseMiniController
'product_spec' => $product->spec,
'unit' => (string) $product->unit,
'price' => $price,
'price_unit' => $product->price_unit,
'image_ids' => implode(',', (array) $product->image_ids),
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => 0,
'weight' => $weight,
'amount' => $amount,
'cost_price' => (string) $product->cost_price,
'remark' => '',
@@ -93,7 +125,7 @@ class OrderController extends BaseMiniController
'store_id' => $store->id,
'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_weight' => $totalWeight,
'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark,
@@ -104,6 +136,12 @@ class OrderController extends BaseMiniController
}
StoreOrderItemModel::insert($rows);
// 下单成功后自动清空购物车中已下单的商品
CartModel::query()
->where('store_id', $store->id)
->whereIn('product_id', $productIds)
->delete();
return $order;
});
@@ -287,4 +325,44 @@ class OrderController extends BaseMiniController
return $this->success([], '订单已取消');
}
/**
* 截单时间校验:业务配置 services.order_time_start / order_time_endHH:mm
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
*/
private function assertWithinOrderTimeWindow(): void
{
$parse = static function (mixed $value): ?int {
$value = trim((string) $value);
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
return null;
}
return (int) $matches[1] * 60 + (int) $matches[2];
};
$startText = trim((string) site_config('services.order_time_start', ''));
$endText = trim((string) site_config('services.order_time_end', ''));
$start = $parse($startText);
$end = $parse($endText);
if ($start === null && $end === null) {
return;
}
if ($start !== null && $start === $end) {
return;
}
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
$allowed = match (true) {
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
$start !== null && $end !== null => $now >= $start && $now <= $end,
$start !== null => $now >= $start,
default => $now <= $end,
};
if (! $allowed) {
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
}
}
}
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PaymentModel;
use App\Services\BillNumberService;
use App\Services\WechatMpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -37,10 +38,19 @@ class PaymentController extends BaseMiniController
return $value;
};
// 支付方式开关(Checkbox 配置自动转数组;未配置时默认全部启用)
$payMethods = site_config('pay.pay_methods');
if (! is_array($payMethods)) {
$payMethods = ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online'];
}
return $this->success([
'pay_methods' => array_values($payMethods),
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
'bank_info' => (string) site_config('pay.bank_info', ''),
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp
'mp_appid' => app(WechatMpService::class)->appid(),
]);
}
@@ -152,7 +162,7 @@ class PaymentController extends BaseMiniController
$bills = $payment->bills()
->orderBy('id')
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
->toArray();
$data = $payment->toArray();
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
use App\Models\CustomerLevelModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
@@ -18,14 +19,15 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
class ProductController extends BaseMiniController
{
/** 分类树(全部启用分类,含暂无商品的分类) */
#[GetRoute('/product/categories', authorize: true)]
#[GetRoute('/product/categories', authorize: false)]
public function categories(): JsonResponse
{
return $this->success(ProductCategoryModel::getTreeData(['*'], true));
}
/**
* 商品列表
* 商品列表(登录门店附 cart_id/cart_quantity 供列表直接加减购物车;
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
*/
#[GetRoute('/product/list', authorize: false)]
public function products(Request $request): JsonResponse
@@ -45,27 +47,47 @@ class ProductController extends BaseMiniController
});
}
// 先按分类树展示顺序(与小程序分类栏一致;不在启用分类树中的商品排最后),再按商品排序
$categoryIds = ProductCategoryModel::getTreeOrderedIds(true);
if ($categoryIds !== []) {
$cases = [];
foreach ($categoryIds as $position => $categoryId) {
$cases[] = "WHEN {$categoryId} THEN {$position}";
}
$query->orderByRaw(
'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
);
}
$pageSize = (int) $request->input('pageSize', 10);
$paginator = $query->orderBy('sort')
->orderBy('id')
->paginate($pageSize);
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100与购物车行
$store = $this->optionalStore($request);
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
$cartService = app(CartService::class);
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
$paginator->getCollection()->transform(
static function (ProductModel $product) use ($level): array {
static function (ProductModel $product) use ($level, $cartRows): array {
$row = $product->toArray();
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
$row['price'] = $level !== null
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
: null;
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00'
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
return $row;
}
);
return $this->success($paginator->toArray());
$data = $paginator->toArray();
$data['cart'] = $cartService->summary($store);
return $this->success($data);
}
/**
@@ -89,8 +111,12 @@ class ProductController extends BaseMiniController
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
}
$cartRows = $store !== null ? app(CartService::class)->cartRowMap($store->id) : [];
$data = $product->toArray();
$data['price'] = $price;
$data['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
$data['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
return $this->success($data);
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序运营报表(门店视角:采购金额汇总与单品占比)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class ReportController extends BaseMiniController
{
/**
* 采购金额报表:选定周期内采购总金额 + 每个单品累计采购金额/占比
*
* ?preset=week|last_week|month|last_month 周期预设(默认 month 本月);
* ?start_date=&end_date= 自定义区间(Y-m-d,需成对出现,优先于 preset)。
* 统计口径:本店订货单(排除已取消与后台已删除订单),金额=订货明细快照金额之和,
* 单品按 product_id 聚合(品名/规格/单位取明细快照),按金额降序,占比保留 1 位小数(四舍五入)
*/
#[GetRoute('/report/purchase', authorize: true)]
public function purchase(Request $request): JsonResponse
{
$params = $request->validate([
'preset' => 'nullable|string|in:week,last_week,month,last_month',
'start_date' => 'required_with:end_date|nullable|date_format:Y-m-d',
'end_date' => 'required_with:start_date|nullable|date_format:Y-m-d|after_or_equal:start_date',
], [
'preset.in' => 'preset 参数只能是 week/last_week/month/last_month',
'start_date.required_with' => '自定义区间需同时提供开始与结束日期',
'end_date.required_with' => '自定义区间需同时提供开始与结束日期',
'start_date.date_format' => '开始日期格式为 Y-m-d',
'end_date.date_format' => '结束日期格式为 Y-m-d',
'end_date.after_or_equal' => '结束日期不能早于开始日期',
]);
$store = $this->currentStore($request);
[$startDate, $endDate, $preset] = $this->resolveDateRange($params);
// 明细联订货单:按订单日期/状态过滤(已取消、已删除订单不计入)
$baseQuery = StoreOrderItemModel::query()
->join('store_order', static function ($join): void {
$join->on('store_order.id', '=', 'store_order_item.order_id')
->whereNull('store_order.deleted_at');
})
->where('store_order_item.store_id', $store->id)
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
->whereDate('store_order.order_date', '>=', $startDate)
->whereDate('store_order.order_date', '<=', $endDate);
$totals = (clone $baseQuery)
->selectRaw('COALESCE(SUM(store_order_item.amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(store_order_item.quantity), 0) as total_quantity')
->selectRaw('COUNT(DISTINCT store_order_item.order_id) as order_count')
->first();
$totalAmount = bcadd((string) $totals->total_amount, '0', 2);
$rows = (clone $baseQuery)
->select('store_order_item.product_id')
->selectRaw('MAX(store_order_item.product_name) as product_name')
->selectRaw('MAX(store_order_item.product_spec) as product_spec')
->selectRaw('MAX(store_order_item.unit) as unit')
->selectRaw('COALESCE(SUM(store_order_item.quantity), 0) as quantity')
->selectRaw('COALESCE(SUM(store_order_item.weight), 0) as weight')
->selectRaw('COALESCE(SUM(store_order_item.amount), 0) as amount')
->groupBy('store_order_item.product_id')
->orderByDesc('amount')
->orderBy('store_order_item.product_id')
->get();
$items = $rows->map(static function (StoreOrderItemModel $row) use ($totalAmount): array {
// 占比 %(1 位小数,四舍五入):tenths = round(amount × 1000 ÷ total),再 ÷10
$percent = 0.0;
if (bccomp($totalAmount, '0', 2) > 0) {
$tenths = bcdiv(
bcadd(bcmul((string) $row->amount, '1000', 2), bcdiv($totalAmount, '2', 0), 2),
$totalAmount,
0
);
$percent = (float) bcdiv($tenths, '10', 1);
}
return [
'product_id' => (int) $row->product_id,
'product_name' => (string) $row->product_name,
'product_spec' => (string) $row->product_spec,
'unit' => (string) $row->unit,
'quantity' => (int) $row->quantity,
'weight' => bcadd((string) $row->weight, '0', 3),
'amount' => bcadd((string) $row->amount, '0', 2),
'percent' => $percent,
];
})->values()->all();
return $this->success([
'preset' => $preset,
'start_date' => $startDate,
'end_date' => $endDate,
'total_amount' => $totalAmount,
'total_quantity' => (int) $totals->total_quantity,
'order_count' => (int) $totals->order_count,
'item_count' => count($items),
'items' => $items,
]);
}
/**
* 解析统计区间:自定义日期优先;否则按 preset 推导(进行中的周期封顶到今天)
*
* @param array{preset?: ?string, start_date?: ?string, end_date?: ?string} $params
* @return array{0: string, 1: string, 2: string} [开始日期, 结束日期, 实际 preset]
*/
private function resolveDateRange(array $params): array
{
if (! empty($params['start_date']) && ! empty($params['end_date'])) {
return [$params['start_date'], $params['end_date'], 'custom'];
}
$preset = (string) ($params['preset'] ?? 'month');
$today = Carbon::today();
[$start, $end] = match ($preset) {
'week' => [$today->copy()->startOfWeek(), $today->copy()->endOfWeek()],
'last_week' => [$today->copy()->subWeek()->startOfWeek(), $today->copy()->subWeek()->endOfWeek()],
'last_month' => [$today->copy()->subMonthNoOverflow()->startOfMonth(), $today->copy()->subMonthNoOverflow()->endOfMonth()],
default => [$today->copy()->startOfMonth(), $today->copy()->endOfMonth()],
};
// 未来日期本无数据,封顶到今天避免误解
if ($end->greaterThan($today)) {
$end = $today->copy();
}
return [$start->toDateString(), $end->toDateString(), $preset];
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Models\CustomerLevelModel;
use App\Models\HomeSpecialModel;
use App\Models\ProductModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序特价推荐
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class SpecialController extends BaseMiniController
{
/**
* 特价推荐商品列表(免登录浏览;行结构与 /mini/product/list 一致:
* 登录门店附等级价 price 与 cart_id/cart_quantity 供列表直接加减购物车;
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
*/
#[GetRoute('/special/list', authorize: false)]
public function specials(Request $request): JsonResponse
{
$pageSize = (int) $request->input('pageSize', 10);
$paginator = HomeSpecialModel::query()
->where('status', HomeSpecialModel::STATUS_NORMAL)
->whereHas('product', static function ($q) {
$q->where('status', ProductModel::STATUS_ON);
})
->with('product')
->orderBy('sort')
->orderBy('id')
->paginate($pageSize);
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)与购物车行
$store = $this->optionalStore($request);
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
$cartService = app(CartService::class);
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
$paginator->getCollection()->transform(
static function (HomeSpecialModel $special) use ($level, $cartRows): array {
$product = $special->product;
$row = $product->toArray();
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
$row['price'] = $level !== null
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
: null;
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00'
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
return $row;
}
);
$data = $paginator->toArray();
$data['cart'] = $cartService->summary($store);
return $this->success($data);
}
}
@@ -52,6 +52,15 @@ class StoreOrderController extends BaseController
});
}
// 按采购单号模糊搜索(关联采购单)
$purchaseNo = trim((string) ($params['purchase_no'] ?? ''));
if ($purchaseNo !== '') {
$keyword = '%' . str_replace('%', '\%', $purchaseNo) . '%';
$query->whereHas('purchase', static function ($purchaseQuery) use ($keyword) {
$purchaseQuery->where('purchase_no', 'like', $keyword);
});
}
$data = $this->buildSearch($params, $query)
->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
@@ -3,8 +3,10 @@
namespace App\Http\Controllers\Product;
use App\Exceptions\RepositoryException;
use App\Exports\ProductExport;
use App\Http\Requests\Product\BatchPriceRequest;
use App\Http\Requests\Product\ProductFormRequest;
use App\Imports\ProductImport;
use App\Models\CustomerLevelModel;
use App\Models\NoticeModel;
use App\Models\ProductModel;
@@ -13,6 +15,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
@@ -20,6 +23,7 @@ use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Services\SysFileService;
use Symfony\Component\HttpFoundation\Response;
/**
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
@@ -31,6 +35,7 @@ class ProductController extends BaseController
'name' => 'like',
'category_id' => '=',
'supplier_id' => '=',
'market' => 'like',
'status' => '=',
];
@@ -90,6 +95,53 @@ class ProductController extends BaseController
return $this->success(['id' => $product->id]);
}
/**
* 商品导出(列格式与导入模板一致,导出文件修改后可直接重新导入);
* template=1 时仅输出列头 + 示例行(导入模板)
*/
#[GetRoute('/export', 'export')]
public function export(Request $request): Response
{
$template = $request->boolean('template');
$categoryId = max(0, (int) $request->query('category_id', 0));
$filename = $template
? '商品导入模板.xlsx'
: '商品列表_' . now()->format('Ymd_His') . '.xlsx';
return Excel::download(new ProductExport($categoryId, $template), $filename);
}
/**
* Excel 批量导入商品:整表校验,有错全部不导入;
* 分类按名称/路径匹配末级分类,供应商按名称匹配、不存在自动创建;一律新增商品
*/
#[PostRoute('/import', 'import')]
public function import(Request $request): JsonResponse
{
$data = $request->validate([
'file' => 'required|file|mimes:xlsx,xls|max:10240',
], [
'file.required' => '请选择要导入的 Excel 文件',
'file.mimes' => '仅支持 xlsx/xls 格式的 Excel 文件',
'file.max' => '文件大小不能超过 10MB',
]);
$import = new ProductImport();
Excel::import($import, $data['file']);
if ($import->errors !== []) {
return $this->error(
['errors' => $import->errors],
'共发现 ' . count($import->errors) . ' 处数据错误,请修正后重新导入'
);
}
$msg = '成功导入 ' . $import->created . ' 件商品';
if ($import->suppliersCreated !== []) {
$msg .= ',自动创建供应商:' . implode('、', $import->suppliersCreated);
}
return $this->success(['created' => $import->created], $msg);
}
/** 编辑商品 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ProductFormRequest $request): JsonResponse
@@ -114,6 +166,23 @@ class ProductController extends BaseController
return $this->success();
}
/** 批量删除商品(软删除),复用 delete 权限点 */
#[DeleteRoute('/batch', 'delete')]
public function batchDelete(Request $request): JsonResponse
{
$data = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|distinct',
], [
'ids.required' => '请选择要删除的商品',
'ids.min' => '请选择要删除的商品',
'ids.*.integer' => '商品 ID 格式错误',
'ids.*.distinct' => '存在重复的商品',
]);
ProductModel::whereIn('id', $data['ids'])->delete();
return $this->success();
}
/**
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
@@ -13,6 +13,7 @@ use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
use App\Models\BillModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -22,6 +23,7 @@ use App\Services\BillGenerateService;
use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService;
use App\Services\PurchaseItemService;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -53,7 +55,10 @@ class PurchaseOrderController extends BaseController
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
$data = $this->buildSearch($params, PurchaseOrderModel::query()
->with('operator:id,nickname')
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
->withSum('bills as bill_product_amount', 'product_amount'))
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
@@ -105,7 +110,7 @@ class PurchaseOrderController extends BaseController
$quantity = 0;
// 采购总重量
$weight = '0';
// 采购总金额(Σ明细 amount,参考零售价按 金额÷数量 加权)
// 采购总金额(Σ明细 amount,价按 金额÷数量 加权)
$amount = '0';
// 门店明细
$cellsMap = [];
@@ -134,6 +139,7 @@ class PurchaseOrderController extends BaseController
'unit' => $first->unit, // 单位
'supplier_id' => (int) $first->supplier_id, // 供应商id
'supplier' => $supplier, // 供应商
'market' => (string) ($product?->market ?? ''), // 市场(取商品档案)
'cost_price' => $first->cost_price, // 成本价
'quantity' => $quantity,
'weight' => (float) $weight,
@@ -171,6 +177,12 @@ class PurchaseOrderController extends BaseController
return $row;
}, $rows),
'bills' => $bills,
// 单品「已对账」标记(入库持久化,商品ID列表)
'checked_product_ids' => PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->pluck('product_id')
->map(static fn ($v) => (int) $v)
->all(),
]);
}
@@ -480,7 +492,10 @@ class PurchaseOrderController extends BaseController
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => bcadd((string) ($validated['weight'] ?? 0), '0', 3),
// 未传称重时按订货量 × 规格预填参考重量
'weight' => isset($validated['weight'])
? bcadd((string) $validated['weight'], '0', 3)
: WeightEstimator::estimate((string) $product->spec, (string) $product->unit, (string) $quantity),
'amount' => bcmul($price, (string) $quantity, 2),
'cost_price' => (string) $product->cost_price,
'remark' => '',
@@ -608,8 +623,9 @@ class PurchaseOrderController extends BaseController
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name'])
->get(['id', 'name', 'level_id'])
->keyBy('id');
$bills = BillModel::query()
@@ -627,9 +643,13 @@ class PurchaseOrderController extends BaseController
'0'
);
$bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
'store_name' => $store->name ?? ('门店#' . $storeId),
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
'level_name' => $store?->level?->name,
'level_percent' => (string) ($store?->level?->percent ?? '0'),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
@@ -790,6 +810,103 @@ class PurchaseOrderController extends BaseController
});
}
/**
* 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选)
*/
#[PutRoute(route: '/{id}/check/{productId}', authorize: 'query', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
public function toggleItemCheck(int $id, int $productId, Request $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$hasItem = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->exists();
if (! $hasItem) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
$marked = PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->first();
if ($marked !== null) {
$marked->delete();
return $this->success(['checked' => false], '已取消对账标记');
}
PurchaseItemCheckModel::create([
'purchase_id' => $purchase->id,
'product_id' => $productId,
'operator_id' => (int) $request->user()->id,
]);
return $this->success(['checked' => true], '已标记为已对账');
}
/**
* 批量设置单品「已对账」标记(全选用:checked=true 批量标记 / false 批量取消;
* 仅处理本采购单内有订货明细的商品,重复标记幂等;与采购单状态无关)
*/
#[PutRoute(route: '/{id}/check', authorize: 'query', where: ['id' => '[0-9]+'])]
public function batchItemCheck(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'product_ids' => 'required|array|min:1',
'product_ids.*' => 'integer',
'checked' => 'required|boolean',
], [
'product_ids.required' => '请选择商品',
'product_ids.min' => '请选择商品',
]);
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
// 仅处理本采购单内有订货明细的商品(无效商品静默忽略)
$productIds = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', array_map('intval', $data['product_ids']))
->distinct()
->pluck('product_id')
->map(static fn ($v) => (int) $v);
if (! (bool) $data['checked']) {
PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', $productIds)
->delete();
return $this->success(['count' => $productIds->count()], '已取消对账标记');
}
// 批量标记:跳过已标记行,仅补插缺失行(幂等)
$marked = PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', $productIds)
->pluck('product_id')
->map(static fn ($v) => (int) $v);
$operatorId = (int) $request->user()->id;
$now = now();
$rows = $productIds->diff($marked)
->map(static fn (int $productId) => [
'purchase_id' => $purchase->id,
'product_id' => $productId,
'operator_id' => $operatorId,
'created_at' => $now,
'updated_at' => $now,
])
->values()
->all();
if ($rows !== []) {
PurchaseItemCheckModel::insert($rows);
}
return $this->success(['count' => $productIds->count()], '已标记为已对账');
}
/**
* 采购单编辑闸:仅进行中(待采购)允许修改明细
*/
@@ -24,6 +24,7 @@ class PaymentController extends BaseController
protected array $searchField = [
'store_id' => '=',
'status' => '=',
'pay_type' => '=',
'pay_method' => '=',
'payment_no' => 'like',
];
@@ -55,7 +56,7 @@ class PaymentController extends BaseController
$bills = $payment->bills()
->orderBy('id')
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
->toArray();
$data = $payment->toArray();
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Client;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 特价推荐 批量添加 验证
*/
class SpecialBatchAddRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_ids' => 'required|array|min:1',
'product_ids.*' => 'integer|distinct|exists:product,id',
];
}
public function messages(): array
{
return [
'product_ids.required' => '请选择要添加的商品',
'product_ids.min' => '请选择要添加的商品',
'product_ids.*.integer' => '商品 ID 格式错误',
'product_ids.*.distinct' => '存在重复的商品',
'product_ids.*.exists' => '所选商品不存在',
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Client;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 特价推荐 编辑 验证(仅排序与状态可编辑,商品不可更换)
*/
class SpecialFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'sort' => 'nullable|integer|min:0',
'status' => 'required|integer|in:0,1',
];
}
public function messages(): array
{
return [
'sort.min' => '排序不能小于 0',
'status.in' => '状态只能是 0 或 1',
];
}
}
@@ -22,9 +22,11 @@ class ProductFormRequest extends BaseFormRequest
return [
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
'market' => 'nullable|string|max:50',
'name' => 'required|string|max:100',
'spec' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:20',
'price_unit' => 'nullable|string|max:20',
'image_ids' => 'nullable|array|max:255',
'image_ids.*' => ['integer', new Exists(SysFileModel::class, 'id')],
'content' => 'nullable|string',
@@ -5,8 +5,8 @@ namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改;
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额)
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后金额/备注,金额由系统汇总不可修改;
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额;售后金额可正负,计入总金额
*/
class PurchaseBillGenerateRequest extends BaseFormRequest
{
@@ -20,6 +20,8 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer',
'stores.*.tray_num' => 'required|integer',
'stores.*.after_sale' => 'nullable|numeric',
'stores.*.remark' => 'nullable|string|max:255',
];
}
@@ -39,6 +41,8 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.after_sale.numeric' => '售后金额格式错误(可正负)',
'stores.*.remark.max' => '备注最长 255 个字符',
];
}
}
+363
View File
@@ -0,0 +1,363 @@
<?php
namespace App\Imports;
use App\Exports\ProductExport;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\SupplierModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Concerns\ToCollection;
/**
* 商品档案 Excel 导入:整表校验,任何一行有错则全部不导入(返回全部错误行号+原因)。
* 规则:品名必填;分类按名称/「父分类/子分类」路径匹配末级分类;供应商按名称匹配、不存在自动创建;
* 导入一律新增商品,不做匹配更新。
*/
class ProductImport implements ToCollection
{
/** 单次导入数据行上限(防误传超大文件) */
private const int MAX_ROWS = 1000;
/** @var array<int, array{row: int, message: string}> 校验错误行(Excel 行号,1 起) */
public array $errors = [];
/** 成功写入的商品数 */
public int $created = 0;
/** @var array<int, string> 本次自动创建的供应商名称 */
public array $suppliersCreated = [];
/**
* maatwebsite 入口:读取首个工作表全部行(第 1 行为列头)
*
* @param Collection<int, Collection<int, mixed>> $rows
*/
public function collection(Collection $rows): void
{
$header = $rows->first();
if (! $this->isValidHeader($header)) {
$this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试'];
return;
}
$dataRows = $rows->slice(1)->values();
if ($dataRows->count() > self::MAX_ROWS) {
$this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入'];
return;
}
$categories = ProductCategoryModel::all();
$leafIds = $this->leafCategoryIds($categories);
$parsed = [];
foreach ($dataRows as $index => $row) {
$excelRow = $index + 2; // Excel 行号(1 起,含列头行)
$cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray());
// 整行空白视为空行跳过
if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') {
continue;
}
$result = $this->parseRow($cells, $categories, $leafIds);
if ($result['errors'] !== []) {
foreach ($result['errors'] as $message) {
$this->errors[] = ['row' => $excelRow, 'message' => $message];
}
} else {
$parsed[] = $result['data'];
}
}
if ($this->errors !== []) {
return; // 整表校验:有错一行不写
}
if ($parsed === []) {
$this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行'];
return;
}
$this->writeRows($parsed);
}
/**
* 解析并校验一行数据;全部合法时返回写入字段
*
* @param array<int, mixed> $cells
* @param Collection<int, ProductCategoryModel> $categories
* @param array<int, true> $leafIds
* @return array{data: array<string, mixed>, errors: array<int, string>}
*/
private function parseRow(array $cells, Collection $categories, array $leafIds): array
{
[$name, $categoryName, $supplierName, $market, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] =
array_pad(array_slice($cells, 0, 12), 12, null);
$errors = [];
$name = (string) $name;
if ($name === '') {
$errors[] = '品名不能为空';
} elseif (mb_strlen($name) > 100) {
$errors[] = '品名最长 100 个字符';
}
$categoryId = 0;
$categoryName = (string) $categoryName;
if ($categoryName === '') {
$errors[] = '分类不能为空';
} else {
$categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds);
if ($categoryId === 0) {
$errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)';
}
}
$supplierName = (string) $supplierName;
if (mb_strlen($supplierName) > 100) {
$errors[] = '供应商名称最长 100 个字符';
}
$market = (string) $market;
if (mb_strlen($market) > 50) {
$errors[] = '市场最长 50 个字符';
}
$spec = (string) $spec;
if (mb_strlen($spec) > 100) {
$errors[] = '规格/包规最长 100 个字符';
}
$unit = (string) $unit === '' ? '斤' : (string) $unit;
if (mb_strlen($unit) > 20) {
$errors[] = '单位最长 20 个字符';
}
$cost = $this->parseDecimal($costPrice, 0, 99999999);
if ($cost === null) {
$errors[] = '成本价必须为不小于 0 的数字';
}
$sort = $this->parseInteger($sort);
if ($sort === null) {
$errors[] = '排序必须为不小于 0 的整数';
}
$stock = $this->parseInteger($stock);
if ($stock === null) {
$errors[] = '库存必须为不小于 0 的整数';
}
$shelfLife = $this->parseInteger($shelfLife);
if ($shelfLife === null) {
$errors[] = '保质期必须为不小于 0 的整数';
}
$status = $this->parseStatus($statusText);
if ($status === null) {
$errors[] = '状态只能填「上架」或「下架」';
}
$remark = (string) $remark;
if (mb_strlen($remark) > 255) {
$errors[] = '备注最长 255 个字符';
}
if ($errors !== []) {
return ['data' => [], 'errors' => $errors];
}
return [
'data' => [
'category_id' => $categoryId,
'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建)
'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表
'market' => $market,
'name' => $name,
'spec' => $spec,
'unit' => $unit,
'image_ids' => '',
'content' => '',
'sort' => $sort,
'shelf_life' => $shelfLife,
'stock' => $stock,
'status' => $status,
'cost_price' => $cost,
'remark' => $remark,
],
'errors' => [],
];
}
/**
* 事务写入:自动创建缺失供应商,批量插入商品
*
* @param array<int, array<string, mixed>> $parsed
*/
private function writeRows(array $parsed): void
{
DB::transaction(function () use ($parsed) {
// 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建)
$supplierIds = SupplierModel::pluck('id', 'name');
foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) {
if (! isset($supplierIds[$name])) {
$supplier = SupplierModel::create(['name' => $name]);
$supplierIds[$name] = $supplier->id;
$this->suppliersCreated[] = $name;
}
}
$now = now();
$payload = array_map(static function (array $row) use ($supplierIds, $now) {
if ($row['supplier_name'] !== '') {
$row['supplier_id'] = (int) $supplierIds[$row['supplier_name']];
}
unset($row['supplier_name']);
$row['created_at'] = $now;
$row['updated_at'] = $now;
return $row;
}, $parsed);
ProductModel::insert($payload);
$this->created = count($payload);
});
}
/**
* 分类名称/路径 → 末级分类ID(0 = 解析失败)
* 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一
*
* @param Collection<int, ProductCategoryModel> $categories
* @param array<int, true> $leafIds
*/
private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int
{
// 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文
$segments = array_values(array_filter(array_map(
static fn ($segment) => trim($segment),
preg_split('#[/\\\\]#u', $name) ?: []
), static fn ($segment) => $segment !== ''));
if ($segments === []) {
return 0;
}
if (count($segments) > 1) {
// 路径逐级匹配
$parentId = 0;
$matched = null;
foreach ($segments as $segment) {
$matched = $categories->first(
static fn (ProductCategoryModel $category) => $category->name === $segment
&& (int) $category->parent_id === $parentId
);
if ($matched === null) {
return 0;
}
$parentId = (int) $matched->id;
}
return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0;
}
// 单名称:仅在末级分类中唯一时命中
$leafMatches = $categories->filter(
static fn (ProductCategoryModel $category) => $category->name === $segments[0]
&& isset($leafIds[(int) $category->id])
);
if ($leafMatches->count() !== 1) {
return 0;
}
return (int) $leafMatches->first()->id;
}
/**
* 末级分类ID集合(无任何子分类)
*
* @param Collection<int, ProductCategoryModel> $categories
* @return array<int, true>
*/
private function leafCategoryIds(Collection $categories): array
{
$parentIds = [];
foreach ($categories as $category) {
if ((int) $category->parent_id > 0) {
$parentIds[(int) $category->parent_id] = true;
}
}
$leafIds = [];
foreach ($categories as $category) {
if (! isset($parentIds[(int) $category->id])) {
$leafIds[(int) $category->id] = true;
}
}
return $leafIds;
}
/**
* 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格)
*/
private function isValidHeader(?Collection $header): bool
{
if ($header === null) {
return false;
}
$cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray());
foreach (ProductExport::HEADERS as $index => $expected) {
if (($cells[$index] ?? '') !== $expected) {
return false;
}
}
return true;
}
/**
* 解析十进制数字(空串取默认值;越界/非数字返回 null)
*/
private function parseDecimal(mixed $value, float $min, float $max): ?string
{
if ($value === null || $value === '') {
$value = 0;
}
if (! is_numeric($value)) {
return null;
}
$number = (float) $value;
if ($number < $min || $number > $max) {
return null;
}
return number_format($number, 2, '.', '');
}
/**
* 解析非负整数(空串取 0;非整数/负数返回 null)
*/
private function parseInteger(mixed $value): ?int
{
if ($value === null || $value === '') {
return 0;
}
if (! is_numeric($value)) {
return null;
}
$number = (float) $value;
if ($number < 0 || $number !== (float) (int) $number) {
return null;
}
return (int) $number;
}
/**
* 解析状态(空串默认上架;接受 上架/下架/1/0)
*/
private function parseStatus(mixed $value): ?int
{
$text = trim((string) $value);
return match ($text) {
'', '上架', '1' => ProductModel::STATUS_ON,
'下架', '0' => ProductModel::STATUS_OFF,
default => null,
};
}
}
+2
View File
@@ -75,6 +75,7 @@ class BillModel extends Model
'paid_operator_id',
'operator_id',
'remark',
'after_sale',
];
protected $casts = [
@@ -89,6 +90,7 @@ class BillModel extends Model
'tray_price' => 'decimal:2',
'added_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'after_sale' => 'decimal:2',
'status' => 'integer',
'payment_id' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 小程序首页特价推荐商品
*/
class HomeSpecialModel extends Model
{
/** 状态:停用 */
public const int STATUS_DISABLED = 0;
/** 状态:正常 */
public const int STATUS_NORMAL = 1;
protected $table = 'mini_home_special';
protected $primaryKey = 'id';
protected $fillable = [
'product_id',
'sort',
'status',
];
protected $casts = [
'product_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 关联商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(ProductModel::class, 'product_id');
}
}
+50 -4
View File
@@ -11,31 +11,49 @@ use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemUser\Models\SysUserModel;
/**
* 支付记录模型(小程序选择门店账单合并付款,提交汇款凭证;后台审核通过后关联账单批量置已支付
* 支付记录模型(小程序选择门店账单合并付款)
*
* 支付类型 pay_type
* - 1 线下凭证支付:门店提交汇款凭证,后台审核通过后关联账单批量置已支付
* - 2 旺铺在线支付:调起微信/支付宝在线支付,网关回调(或主动查询)确认后自动结账
*/
class PaymentModel extends Model
{
use HasFactory;
/** 支付类型:线下凭证支付 */
public const int TYPE_OFFLINE = 1;
/** 支付类型:旺铺在线支付 */
public const int TYPE_ONLINE = 2;
/** 支付类型中文名 */
public const array TYPE_NAMES = [
self::TYPE_OFFLINE => '凭证支付',
self::TYPE_ONLINE => '在线支付',
];
/** 支付方式:微信 */
public const int METHOD_WECHAT = 1;
/** 支付方式:支付宝 */
public const int METHOD_ALIPAY = 2;
/** 支付方式:对公汇款(银行卡) */
public const int METHOD_BANK = 3;
/** 支付方式:旺铺在线支付(微信/支付宝小程序 JSAPI) */
public const int METHOD_WANGPU = 4;
/** 支付方式中文名 */
public const array METHOD_NAMES = [
self::METHOD_WECHAT => '微信支付',
self::METHOD_ALIPAY => '支付宝',
self::METHOD_BANK => '对公汇款',
self::METHOD_WANGPU => '旺铺支付',
];
/** 状态:待审核 */
/** 状态:待审核(线下凭证)/ 待支付(在线支付) */
public const int STATUS_PENDING = 0;
/** 状态:已通过 */
/** 状态:已通过(线下凭证)/ 支付成功(在线支付) */
public const int STATUS_APPROVED = 1;
/** 状态:已拒绝 */
/** 状态:已拒绝(线下凭证)/ 支付失败(在线支付) */
public const int STATUS_REJECTED = 2;
/** 状态中文名 */
@@ -45,6 +63,13 @@ class PaymentModel extends Model
self::STATUS_REJECTED => '已拒绝',
];
/** 在线支付状态中文名(pay_type=2 时使用) */
public const array ONLINE_STATUS_NAMES = [
self::STATUS_PENDING => '待支付',
self::STATUS_APPROVED => '支付成功',
self::STATUS_REJECTED => '支付失败',
];
protected $table = 'payment';
protected $primaryKey = 'id';
@@ -52,9 +77,15 @@ class PaymentModel extends Model
'payment_no',
'store_id',
'amount',
'pay_type',
'pay_method',
'voucher_ids',
'status',
'order_id',
'trade_no',
'openid',
'paid_at',
'pay_params',
'remark',
'audited_at',
'auditor_id',
@@ -64,13 +95,28 @@ class PaymentModel extends Model
protected $casts = [
'store_id' => 'integer',
'amount' => 'decimal:2',
'pay_type' => 'integer',
'pay_method' => 'integer',
'status' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
'audited_at' => 'datetime:Y-m-d H:i:s',
'auditor_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 旺铺下单返回的调起支付参数(JSON 字符串 ↔ 数组)
*
* @return Attribute<array<string, mixed>, string>
*/
public function payParams(): Attribute
{
return Attribute::make(
get: fn ($value) => $value === '' || $value === null ? [] : (json_decode((string) $value, true) ?: []),
set: fn ($value) => is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : $value,
);
}
/**
* 汇款凭证图片ID(逗号分隔字符串 ↔ 数组)
*/
+30
View File
@@ -74,6 +74,36 @@ class ProductCategoryModel extends Model
return static::buildTree($query->get($columns)->toArray());
}
/**
* 按树展示顺序(sort/id 深度优先)获取分类 ID 列表,供商品列表等按分类顺序排序
*
* @param bool $onlyEnabled 是否仅包含启用分类
* @return array<int, int>
*/
public static function getTreeOrderedIds(bool $onlyEnabled = false): array
{
return static::flattenTreeIds(static::getTreeData(['id', 'parent_id'], $onlyEnabled));
}
/**
* 深度优先展开分类树为有序 ID 列表
*
* @param array<int, array<string, mixed>> $tree
* @return array<int, int>
*/
private static function flattenTreeIds(array $tree): array
{
$ids = [];
foreach ($tree as $node) {
$ids[] = (int) $node['id'];
if (!empty($node['children'])) {
$ids = [...$ids, ...static::flattenTreeIds($node['children'])];
}
}
return $ids;
}
/**
* 将扁平分类列表组装为树
*
+3 -1
View File
@@ -27,9 +27,11 @@ class ProductModel extends Model
protected $fillable = [
'category_id',
'supplier_id',
'market',
'name',
'spec',
'unit',
'price_unit',
'image_ids',
'content',
'sort',
@@ -66,7 +68,7 @@ class ProductModel extends Model
public function imageIds(): Attribute
{
return Attribute::make(
get: fn ($value) => explode(',', $value),
get: fn ($value) => $value ? explode(',', $value) : [],
set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
);
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
*/
class PurchaseItemCheckModel extends Model
{
protected $table = 'purchase_item_check';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_id',
'product_id',
'operator_id',
];
protected $casts = [
'purchase_id' => 'integer',
'product_id' => 'integer',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 标记人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+10
View File
@@ -44,6 +44,8 @@ class PurchaseOrderModel extends Model
'actual_amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
// 列表 withSum 聚合属性(应付商品金额;无账单时保持 null)
'bill_product_amount' => 'decimal:2',
];
/**
@@ -69,4 +71,12 @@ class PurchaseOrderModel extends Model
{
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
}
/**
* 本采购单生成的门店账单
*/
public function bills(): HasMany
{
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
}
}
+2
View File
@@ -37,6 +37,8 @@ class StoreModel extends Authenticatable
'phone',
'address',
'payment_cycle_days',
'openid',
'mp_openid',
'status',
'remark',
];
+1
View File
@@ -30,6 +30,7 @@ class StoreOrderItemModel extends Model
'product_spec',
'unit',
'price',
'price_unit',
'image_ids',
'content',
'shelf_life',
+2
View File
@@ -85,6 +85,8 @@ class BillDetailService
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'spec' => $product->spec,
'price_unit' => $product->price_unit,
'image_ids' => (array) $first->image_ids,
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
+22 -4
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -20,8 +21,10 @@ use Throwable;
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 5. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/
readonly class BillGenerateService
{
@@ -31,7 +34,7 @@ readonly class BillGenerateService
/**
* @param PurchaseOrderModel $purchase 已完成采购单
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int, after_sale?: string|int|float, remark?: string}> $stores 按门店提交的配送费/周转筐/托盘数量与售后金额/备注
* @param int $operatorId 生成人(后台系统用户ID)
* @return BillModel[] 生成的账单列表
* @throws Throwable
@@ -81,6 +84,14 @@ readonly class BillGenerateService
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
$levelPercents = StoreModel::withTrashed()
->with('level:id,percent')
->whereIn('id', $ordersByStore->keys())
->get(['id', 'level_id'])
->mapWithKeys(static fn (StoreModel $store) => [
$store->id => (string) ($store->level?->percent ?? '0'),
]);
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
@@ -95,7 +106,12 @@ readonly class BillGenerateService
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2
);
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
// 售后金额按客户等级上浮比例折算(输入 15、上浮 1% → 实收 15.15;负数同比例放大)
$afterSale = CustomerLevelModel::calcLevelPrice(
(string) ($row['after_sale'] ?? '0'),
$levelPercents[(int) $storeId] ?? '0',
);
$totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
$bill = BillModel::create([
'bill_no' => $this->billNumberService->make('ZD'),
@@ -111,6 +127,8 @@ readonly class BillGenerateService
'added_amount' => $addedAmount,
'total_amount' => $totalAmount,
'operator_id' => $operatorId,
'after_sale' => $afterSale,
'remark' => (string) ($row['remark'] ?? ''),
]);
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\StoreModel;
/**
* 购物车共享服务:商品列表/详情附加购物车数量、悬浮球汇总(种数/总数量/总金额)
*
* 汇总口径(与 Mini/CartController::index 一致,勿偏离):
* - total_count = 购物车全部行数(含已下架等不可购项)
* - total_quantity / total_amount = 仅统计可购项(商品在上架且门店已设等级),
* 金额 = Σ 数量 × 等级上浮价(CustomerLevelModel::calcLevelPrice
*/
class CartService
{
/**
* 门店购物车行映射:product_id => ['id' => 购物车行ID, 'quantity' => 数量字符串]
* (商品列表/详情附加 cart_id/cart_quantity 用;行ID供直接加减/删除操作)
*
* @return array<int, array{id: int, quantity: string}>
*/
public function cartRowMap(int $storeId): array
{
return CartModel::query()
->where('store_id', $storeId)
->get(['id', 'product_id', 'quantity'])
->keyBy('product_id')
->map(static fn (CartModel $row): array => [
'id' => (int) $row->id,
'quantity' => (string) $row->quantity,
])
->all();
}
/**
* 购物车悬浮球汇总:种数/总数量/总金额(未登录或空购物车返回零值结构)
*
* @return array{total_count: int, total_quantity: string, total_amount: string}
*/
public function summary(?StoreModel $store): array
{
$empty = ['total_count' => 0, 'total_quantity' => '0.00', 'total_amount' => '0.00'];
if ($store === null) {
return $empty;
}
$rows = CartModel::query()
->where('store_id', $store->id)
->get(['id', 'product_id', 'quantity']);
if ($rows->isEmpty()) {
return $empty;
}
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $rows->pluck('product_id')->all())
->get(['id', 'cost_price'])
->keyBy('id');
$level = $store->level_id > 0 ? $store->level : null;
$totalQuantity = '0.00';
$totalAmount = '0.00';
foreach ($rows as $row) {
$product = $products->get($row->product_id);
if ($product === null || $level === null) {
continue;
}
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$totalQuantity = bcadd($totalQuantity, (string) $row->quantity, 2);
$totalAmount = bcadd($totalAmount, bcmul($price, (string) $row->quantity, 2), 2);
}
return [
'total_count' => $rows->count(),
'total_quantity' => $totalQuantity,
'total_amount' => $totalAmount,
];
}
}
+444
View File
@@ -0,0 +1,444 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\StoreModel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付)
*
* 链路:门店小程序选择账单合并付款
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2
* → 按当前渠道(site_config pay.online_channel)走旺铺统一下单或微信 JSAPI 下单
* → 返回调起支付参数 → 小程序 wx.requestPayment
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
*
* 已创建的支付单按自身 pay_method 固定在原渠道查询/结账(渠道切换不影响进行中的支付单)。
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
*/
class OnlinePaymentService
{
/** 在线支付渠道:旺铺支付网关 */
public const string CHANNEL_WANGPU = 'wangpu';
/** 在线支付渠道:微信官方支付 */
public const string CHANNEL_WECHAT = 'wechat';
/** 支付场景:微信小程序(wx.login 换 openidwx.requestPayment 调起) */
public const string SCENE_MINI = 'mini';
/** 支付场景:公众号 H5(网页授权换 openidWeixinJSBridge 调起) */
public const string SCENE_MP = 'mp';
public function __construct(
protected BillNumberService $billNumber,
protected WangpuPayService $wangpu,
protected WechatPayService $wxpay,
protected WechatMiniService $wechat,
protected WechatMpService $wechatMp,
) {}
/**
* 当前在线支付渠道(site_config pay.online_channel 优先,回退 env PAY_ONLINE_CHANNEL,默认旺铺)
*/
public function channel(): string
{
$channel = (string) site_config('pay.online_channel', '');
if ($channel === '') {
$channel = (string) config('services.pay.online_channel', self::CHANNEL_WANGPU);
}
return $channel === self::CHANNEL_WECHAT ? self::CHANNEL_WECHAT : self::CHANNEL_WANGPU;
}
/**
* 发起在线支付
*
* @param array<int, int> $billIds 合并付款的账单ID
* @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分)
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
* @throws Throwable
*/
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
{
$channel = $this->channel();
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
// 换取付款人 openid 并绑定到门店(小程序/公众号分场景绑定,两者 openid 维度不同不可混用)
if ($scene === self::SCENE_MP) {
$openid = $this->wechatMp->code2openid($code);
if ((string) $store->mp_openid !== $openid) {
$store->mp_openid = $openid;
$store->save();
}
} else {
$openid = $this->wechat->code2session($code);
if ((string) $store->openid !== $openid) {
$store->openid = $openid;
$store->save();
}
}
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
$bills = BillModel::query()
->where('store_id', $store->id)
->whereIn('id', $billIds)
->lockForUpdate()
->get();
if ($bills->count() !== count($billIds)) {
throw new RepositoryException('包含不属于本店的账单,请刷新后重试');
}
foreach ($bills as $bill) {
if ($bill->status === BillModel::STATUS_PAID) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 已支付,请刷新后重试');
}
if ((int) $bill->payment_id !== 0) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 正在支付中,请勿重复提交');
}
}
$amount = $bills->reduce(
static fn (string $carry, BillModel $bill): string => bcadd($carry, (string) $bill->total_amount, 2),
'0'
);
if (bccomp($amount, '0', 2) <= 0) {
throw new RepositoryException('支付金额必须大于 0');
}
$payment = PaymentModel::create([
'payment_no' => $this->billNumber->make('ZF'),
'store_id' => $store->id,
'amount' => $amount,
'pay_type' => PaymentModel::TYPE_ONLINE,
'pay_method' => $payMethod,
'voucher_ids' => '',
'status' => PaymentModel::STATUS_PENDING,
'openid' => $openid,
'remark' => $remark,
]);
// 锁定账单到本支付记录(支付失败/取消后释放,可重新付款)
BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]);
return [$payment, $bills];
});
try {
$gatewayData = $channel === self::CHANNEL_WECHAT
? $this->wxpay->createOrder(
$payment->payment_no,
(string) $payment->amount,
$openid,
'账单合并付款-' . $payment->payment_no,
$scene === self::SCENE_MP ? $this->wechatMp->appid() : '',
)
: $this->wangpu->createOrder([
'mer_order_id' => $payment->payment_no,
'order_amt' => (string) $payment->amount,
'open_id' => $openid,
'sub_appid' => $this->subAppid($scene),
'payway_code' => $this->wangpu->paywayCode($scene),
'order_title' => '账单合并付款-' . $payment->payment_no,
'notifyurl' => $this->wangpu->notifyUrl(),
]);
} catch (Throwable $e) {
// 网关下单失败:整笔作废并释放账单,门店可重新发起
$this->discard($payment, $e->getMessage());
throw $e;
}
if ($channel === self::CHANNEL_WANGPU) {
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
}
$payment->pay_params = $gatewayData;
$payment->save();
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
$wxjsapiData = $channel === self::CHANNEL_WANGPU
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
: $gatewayData;
return [$payment, $wxjsapiData];
}
/**
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
*
* @param array<string, mixed> $params 通知报文(已验签)
* @return bool 本次是否执行了结账(false = 重复通知)
* @throws Throwable
*/
public function settleByNotify(array $params): bool
{
$merOrderId = (string) ($params['mer_order_id'] ?? '');
$payment = PaymentModel::query()
->where('payment_no', $merOrderId)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在:' . $merOrderId);
}
if ((int) ($params['order_status'] ?? -1) !== WangpuPayService::ORDER_STATUS_PAID) {
throw new RepositoryException('订单未支付成功(order_status=' . ($params['order_status'] ?? '空') . '');
}
return $this->settle(
$payment,
(string) ($params['trade_no'] ?? ''),
(string) ($params['order_id'] ?? ''),
(string) ($params['trade_time'] ?? ''),
(string) ($params['order_amt'] ?? '0'),
);
}
/**
* 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账
*
* @param array<string, mixed> $params 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount
* @return bool 本次是否执行了结账(false = 重复通知)
* @throws Throwable
*/
public function settleByWechatNotify(array $params): bool
{
$outTradeNo = (string) ($params['out_trade_no'] ?? '');
$payment = PaymentModel::query()
->where('payment_no', $outTradeNo)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在:' . $outTradeNo);
}
if (($params['trade_state'] ?? '') !== WechatPayService::TRADE_STATE_SUCCESS) {
throw new RepositoryException('订单未支付成功(trade_state=' . ($params['trade_state'] ?? '空') . '');
}
// 商户号一致性校验,防串号
$mchId = (string) ($params['mchid'] ?? '');
if ($mchId !== '' && $mchId !== $this->wxpayMchId()) {
throw new RepositoryException('通知商户号与配置不一致');
}
return $this->settle(
$payment,
(string) ($params['transaction_id'] ?? ''),
'',
(string) ($params['success_time'] ?? ''),
bcdiv((string) (int) ($params['amount']['total'] ?? 0), '100', 2),
);
}
/**
* 主动查询渠道订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
*
* 按支付单的 pay_method 固定在原渠道查询,渠道切换不影响进行中的支付单
*
* @return array{payment: PaymentModel, order_status: int} 最新支付单与渠道订单状态(1=已支付)
* @throws Throwable
*/
public function queryAndSettle(PaymentModel $payment): array
{
if ($payment->status === PaymentModel::STATUS_APPROVED) {
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
}
if ($payment->pay_method === PaymentModel::METHOD_WECHAT) {
return $this->queryWechatAndSettle($payment);
}
$data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id);
$orderStatus = (int) ($data['order_status'] ?? -1);
if ($orderStatus === WangpuPayService::ORDER_STATUS_PAID) {
$this->settle(
$payment,
(string) ($data['trade_no'] ?? ''),
(string) ($data['order_id'] ?? ''),
(string) ($data['trade_time'] ?? ''),
(string) ($data['order_amt'] ?? '0'),
);
}
return ['payment' => $payment->fresh(), 'order_status' => $orderStatus];
}
/**
* 微信渠道主动查询:trade_state=SUCCESS 则结账
*
* @return array{payment: PaymentModel, order_status: int}
* @throws Throwable
*/
protected function queryWechatAndSettle(PaymentModel $payment): array
{
$data = $this->wxpay->queryOrder($payment->payment_no);
$paid = ($data['trade_state'] ?? '') === WechatPayService::TRADE_STATE_SUCCESS;
if ($paid) {
$this->settle(
$payment,
(string) ($data['transaction_id'] ?? ''),
'',
(string) ($data['success_time'] ?? ''),
bcdiv((string) (int) ($data['amount']['total'] ?? 0), '100', 2),
);
}
return ['payment' => $payment->fresh(), 'order_status' => $paid ? 1 : 0];
}
/**
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
*
* @return bool 本次是否执行了结账(false = 已结账,直接吞掉重复通知)
* @throws Throwable
*/
public function settle(PaymentModel $payment, string $tradeNo, string $orderId, string $tradeTime, string $paidAmount): bool
{
$settled = DB::transaction(function () use ($payment, $tradeNo, $orderId, $tradeTime, $paidAmount) {
$payment = PaymentModel::query()->lockForUpdate()->find($payment->id);
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
if ($payment->status === PaymentModel::STATUS_APPROVED) {
return false; // 幂等:重复通知/查询直接成功
}
if ($payment->pay_type !== PaymentModel::TYPE_ONLINE || $payment->status !== PaymentModel::STATUS_PENDING) {
throw new RepositoryException('支付记录状态异常,无法结账');
}
// 金额一致性校验,防通知篡改
if (bccomp($paidAmount, (string) $payment->amount, 2) !== 0) {
throw new RepositoryException('支付金额与订单金额不一致(通知 ' . $paidAmount . ' / 订单 ' . $payment->amount . '');
}
$bills = $payment->bills()->lockForUpdate()->get();
// 任一账单已通过其他方式收款(如线下登记)则中止,避免重复收款,需人工核实
$paid = $bills->where('status', BillModel::STATUS_PAID);
if ($paid->isNotEmpty()) {
throw new RepositoryException('账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已通过其他方式收款,请人工核实');
}
$paidAt = $this->parseTradeTime($tradeTime);
BillModel::query()->whereIn('id', $bills->pluck('id'))->update([
'status' => BillModel::STATUS_PAID,
'paid_at' => $paidAt,
'paid_operator_id' => 0,
'pay_remark' => (PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $payment->payment_no . '',
]);
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
$store = StoreModel::query()->lockForUpdate()->find($payment->store_id);
if ($store !== null) {
$amount = '0';
foreach ($bills as $bill) {
$amount = bcadd($amount, (string) $bill->product_amount, 2);
}
$store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, $amount, 2);
$store->save();
}
$payment->status = PaymentModel::STATUS_APPROVED;
$payment->trade_no = $tradeNo;
if ($orderId !== '') {
$payment->order_id = $orderId;
}
$payment->paid_at = $paidAt;
$payment->save();
return true;
});
if ($settled) {
$this->notifyStore($payment->fresh());
}
return $settled;
}
/**
* 网关下单失败后作废支付单:置失败并释放账单(保留记录便于排查)
*/
protected function discard(PaymentModel $payment, string $reason): void
{
try {
DB::transaction(function () use ($payment, $reason) {
BillModel::query()
->where('payment_id', $payment->id)
->where('status', BillModel::STATUS_UNPAID)
->update(['payment_id' => 0]);
PaymentModel::query()->where('id', $payment->id)->update([
'status' => PaymentModel::STATUS_REJECTED,
'audit_remark' => mb_substr('网关下单失败:' . $reason, 0, 255),
]);
});
} catch (Throwable $e) {
Log::error('在线支付作废失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 结账成功后通知门店
*/
protected function notifyStore(PaymentModel $payment): void
{
try {
$billsCount = $payment->bills()->count();
NoticeModel::create([
'store_id' => $payment->store_id,
'type' => NoticeModel::TYPE_SYSTEM,
'title' => '账单支付成功',
'content' => mb_substr("您的 {$billsCount} 张账单已通过在线支付完成付款,金额 {$payment->amount} 元(支付单号 {$payment->payment_no}", 0, 500),
'data' => ['payment_id' => $payment->id],
'is_read' => NoticeModel::UNREAD,
]);
} catch (Throwable $e) {
Log::warning('支付成功通知门店失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 网关交易时间解析(格式 yyyy-MM-dd HH:mm:ss,异常回退当前时间)
*/
protected function parseTradeTime(string $tradeTime): Carbon
{
try {
return $tradeTime !== '' ? Carbon::parse($tradeTime) : now();
} catch (Throwable) {
return now();
}
}
/**
* 下单微信子 appid(默认取小程序 appid;公众号 H5 场景取公众号 appid)
*/
protected function subAppid(string $scene = self::SCENE_MINI): string
{
if ($scene === self::SCENE_MP) {
return $this->wechatMp->appid();
}
$subAppid = (string) site_config('wangpu.sub_appid', '');
if ($subAppid === '') {
$subAppid = (string) config('services.wangpu.sub_appid', '');
}
if ($subAppid === '') {
$subAppid = $this->wechat->appid();
}
return trim($subAppid);
}
/**
* 微信渠道商户号(用于通知商户号一致性校验)
*/
protected function wxpayMchId(): string
{
$mchId = (string) site_config('wxpay.mch_id', '');
if ($mchId === '') {
$mchId = (string) config('services.wxpay.mch_id', '');
}
return trim($mchId);
}
}
+6 -1
View File
@@ -63,13 +63,18 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
'0'
);
// 参考总重量 = Σ 明细参考重量(下单时按订货量 × 规格预填)
$totalWeight = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->weight, 3),
'0'
);
$purchase = PurchaseOrderModel::create([
'purchase_no' => $this->billNumberService->make('PO'),
'purchase_date' => $date,
'status' => PurchaseOrderModel::STATUS_PENDING,
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_weight' => $totalWeight,
'estimate_amount' => $estimateAmount,
'actual_amount' => 0,
'operator_id' => $operatorId,
+60 -2
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Database\Eloquent\Collection;
@@ -80,6 +81,7 @@ class PurchaseItemService
'product_name' => $first->product_name,
'product_spec' => $first->product_spec,
'unit' => $first->unit,
'market' => (string) data_get($first, 'market', ''),
// 加权平均单价(保证 单价×数量=预计金额)
'price' => $quantity > 0
? bcdiv($amount, (string) $quantity, 2)
@@ -124,6 +126,7 @@ class PurchaseItemService
'product_name' => $first->product_name,
'product_spec' => $first->product_spec,
'unit' => $first->unit,
'market' => (string) data_get($first, 'market', ''),
'cost_price' => (string) $first->cost_price,
'quantity' => $quantity,
'weight' => $weight,
@@ -136,6 +139,60 @@ class PurchaseItemService
return $this->sortRows($rows);
}
/**
* 供应商采购明细矩阵(导出用):该供应商在采购单内的商品行按市场分组,
* 行附「汇总数量 + 各门店数量」;门店列=本采购单内有该供应商明细的门店(按ID排序)
*
* @return array{
* stores: array<int, string>,
* markets: array<string, array<int, array<string, mixed>>>
* }
*/
public function supplierMarketMatrix(int $purchaseId, int $supplierId): array
{
$items = $this->purchaseItems($purchaseId)
->where('supplier_id', $supplierId)
->sortBy('id');
$storeIds = $items->pluck('store_id')
->map(static fn ($id): int => (int) $id)
->unique()->sort()->values()->all();
$stores = StoreModel::withTrashed()
->whereIn('id', $storeIds)
->orderBy('id')
->pluck('name', 'id')
->toArray();
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$quantity = 0;
$storeQuantities = array_fill_keys(array_map('intval', array_keys($stores)), 0);
foreach ($group as $item) {
$quantity += (int) $item->quantity;
$storeQuantities[(int) $item->store_id] += (int) $item->quantity;
}
$rows[] = [
'product_id' => (int) $productId,
'product_name' => $first->product_name,
'market' => (string) data_get($first, 'market', ''),
'quantity' => $quantity,
'store_quantities' => $storeQuantities,
'category_sort' => (int) data_get($first, 'category_sort', 9999),
'product_sort' => (int) data_get($first, 'product_sort', 9999),
];
}
// 排序后按市场分组(组间/组内均保持 分类sort → 商品sort 顺序)
$markets = [];
foreach ($this->sortRows($rows) as $row) {
$markets[$row['market']][] = $row;
}
return ['stores' => $stores, 'markets' => $markets];
}
/**
* 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键)
*
@@ -151,17 +208,18 @@ class PurchaseItemService
->get()
->makeVisible('cost_price');
// 排序键:商品分类 sort → 商品 sort(商品含软删除,保证历史单据可导出)
// 排序键:商品分类 sort → 商品 sort;市场取商品档案(商品含软删除,保证历史单据可导出)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', $items->pluck('product_id')->unique())
->get(['id', 'category_id', 'sort'])
->get(['id', 'category_id', 'sort', 'market'])
->keyBy('id');
foreach ($items as $item) {
$product = $products->get((int) $item->product_id);
$item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999));
$item->setAttribute('product_sort', (int) ($product?->sort ?? 9999));
$item->setAttribute('market', (string) ($product?->market ?? ''));
}
return $items;
+329
View File
@@ -0,0 +1,329 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密)
*
* 通讯协议(与官方示例 demo/IndexController.php 一致):
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature
* - 整体以 JSON 信封 POST{serialNo, version, timestamp, data, signature, extras, organizNo}
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
*
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key
* MD5utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
*
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.phpenv
*/
class WangpuPayService
{
/** 网关应答成功码 */
public const string CODE_SUCCESS = '0000';
/** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */
public const string NOTIFY_ACK_OK = '00';
/** 订单状态:支付成功(交易查询 / 后台通知的 order_status */
public const int ORDER_STATUS_PAID = 1;
/**
* 统一下单(小程序 JSAPI)
*
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
* @return array<string, mixed> 网关 data 解密后的业务报文(含 order_id/调起支付参数)
* @throws RepositoryException 网关应答失败
*/
public function createOrder(array $params): array
{
return $this->request('/industrial/payment/order', array_merge([
'mer_no' => $this->config('mer_no'),
'mer_code' => $this->config('mer_code'),
'term_code' => $this->config('term_code'),
'payway_code' => $this->config('payway_code'),
'organiz_no' => $this->config('organiz_no'),
], $params));
}
/**
* 交易查询(单笔订单支付状态同步)
*
* @param string $merOrderId 商户唯一订单号(本系统 payment_no
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
* @return array<string, mixed> 网关 data 解密后的业务报文
* @throws RepositoryException 网关应答失败
*/
public function queryOrder(string $merOrderId, string $orderId = ''): array
{
$params = [
'organiz_no' => $this->config('organiz_no'),
'mer_order_id' => $merOrderId,
];
if ($orderId !== '') {
$params['order_id'] = $orderId;
}
return $this->request('/industrial/query/order', $params);
}
/**
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
*
* @param array<string, mixed> $params 通知表单报文(含 sign
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
*/
public function verifyNotify(array $params): array
{
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
throw new RepositoryException('通知报文验签失败');
}
return $params;
}
/**
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
*
* @param array<string, mixed> $params 通知报文
* @throws RepositoryException 加签Key未配置
*/
protected function sign(array $params): string
{
unset($params['sign']);
$params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
ksort($params, SORT_STRING);
$str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params));
$signKey = $this->config('sign_key');
if ($signKey === '') {
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
}
return strtoupper(md5($str . '&key=' . $signKey));
}
/**
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify';
}
/**
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code
*/
public function paywayCode(string $scene = 'mini'): string
{
if ($scene === 'mp') {
$mpPayway = $this->config('mp_payway_code');
if ($mpPayway !== '') {
return $mpPayway;
}
}
return $this->config('payway_code');
}
/**
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
*
* @param array<string, mixed> $params 接口业务参数
* @return array<string, mixed> 应答 data 解密后的业务报文
* @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败
*/
protected function request(string $path, array $params): array
{
$baseUrl = rtrim($this->config('base_url'), '/');
if ($baseUrl === '') {
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
}
$envelope = $this->encryptEnvelope($params);
Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]);
$response = Http::timeout(15)->post($baseUrl . $path, $envelope);
Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
$result = $response->json();
if (! is_array($result)) {
Log::driver('pay')->error('旺铺网关应答异常', [
'path' => $path,
'status' => $response->status(),
'body' => $response->body(),
]);
throw new RepositoryException('支付网关通讯异常,请稍后重试');
}
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
Log::driver('pay')->warning('旺铺网关应答失败', [
'path' => $path,
'response' => $result,
]);
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
}
try {
$data = $this->decryptEnvelope($result);
} catch (RepositoryException $e) {
Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]);
throw new RepositoryException('支付网关应答解密失败,请联系管理员');
}
Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]);
return $data;
}
/**
* 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥
*
* @param array<string, mixed> $params 业务参数
* @return array<string, string> 请求信封
* @throws RepositoryException 加密失败
*/
protected function encryptEnvelope(array $params): array
{
$key = Str::random(16); // AES 密钥(16 字节)
return [
'serialNo' => Str::random(32), // 请求流水号
'version' => '1.0',
'timestamp' => now()->format('YmdHis'),
'data' => urlencode($this->aesEncrypt($params, $key)),
'signature' => urlencode($this->rsaPublicEncrypt($key)),
'extras' => '',
'organizNo' => $this->config('organiz_no'),
];
}
/**
* 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON
*
* @param array<string, mixed> $envelope
* @return array<string, mixed>
* @throws RepositoryException 解密失败
*/
protected function decryptEnvelope(array $envelope): array
{
$data = (string) ($envelope['data'] ?? '');
$signature = (string) ($envelope['signature'] ?? '');
if ($data === '' || $signature === '') {
throw new RepositoryException('报文缺少 data/signature 字段');
}
$plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature));
$decoded = json_decode($plain, true);
if (! is_array($decoded)) {
throw new RepositoryException('报文 JSON 解析失败');
}
return $decoded;
}
/**
* AES-128-ECB 加密(base64 输出,与官方示例算法一致)
*
* @param array<string, mixed> $data
* @throws RepositoryException 加密失败
*/
protected function aesEncrypt(array $data, string $key): string
{
$cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if ($cipher === false) {
throw new RepositoryException('报文 AES 加密失败');
}
return base64_encode($cipher);
}
/**
* AES-128-ECB 解密(base64 输入,与官方示例算法一致)
*
* @throws RepositoryException 解密失败
*/
protected function aesDecrypt(string $cipherBase64, string $key): string
{
$plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if (! is_string($plain)) {
throw new RepositoryException('报文 AES 解密失败');
}
return $plain;
}
/**
* RSA 平台公钥加密(base64 输出,用于加密 AES 密钥)
*
* @throws RepositoryException 加密失败
*/
protected function rsaPublicEncrypt(string $data): string
{
if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) {
throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置');
}
return base64_encode($encrypted);
}
/**
* RSA 商户私钥解密(base64 输入,用于解出 AES 密钥)
*
* @throws RepositoryException 解密失败
*/
protected function rsaPrivateDecrypt(string $cipherBase64): string
{
$ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE'));
if (! $ok || ! is_string($decrypted)) {
throw new RepositoryException('商户私钥解密失败,请检查密钥配置');
}
return $decrypted;
}
/**
* 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符)
*
* 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误
*
* @throws RepositoryException 未配置或密钥无法解析
*/
protected function pemKey(string $configKey, string $kind): string
{
$name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥';
$body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey));
if ($body === '') {
throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员');
}
$wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n"
. wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----";
if ($kind === 'PUBLIC') {
// 公钥兼容 SPKIPUBLIC KEY)与 PKCS#1RSA PUBLIC KEY)两种格式
foreach (['PUBLIC', 'RSA PUBLIC'] as $header) {
if (openssl_pkey_get_public($wrap($header)) !== false) {
return $wrap($header);
}
}
$isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
$pem = $wrap('PRIVATE');
if (openssl_pkey_get_private($pem) === false) {
$isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
return $pem;
}
/**
* 读取网关配置:后台站点配置优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wangpu.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wangpu.' . $key, '');
}
return trim((string) $value);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 微信小程序服务(wx.login 登录凭证校验 code2session
*
* 在线支付前置:用小程序 wx.login() 返回的 code 换取付款人 openid
* 作为旺铺统一下单的 open_id(服务商模式下的微信用户标识)。
*/
class WechatMiniService
{
/**
* wx.login 的 code 换 openid
*
* @return string 用户 openid
* @throws RepositoryException 未配置小程序或凭证校验失败
*/
public function code2session(string $code): string
{
$appid = $this->appid();
$secret = $this->config('mini_secret');
if ($appid === '' || $secret === '') {
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
}
$result = Http::timeout(10)->withOptions([
'verify' => false, // 禁用 SSL 证书验证
])->get('https://api.weixin.qq.com/sns/jscode2session', [
'appid' => $appid,
'secret' => $secret,
'js_code' => $code,
'grant_type' => 'authorization_code',
])->json();
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
if ($openid === '') {
Log::warning('微信 code2session 失败', ['response' => $result]);
throw new RepositoryException('微信登录凭证校验失败:' . ($result['errmsg'] ?? '请重新进入小程序'));
}
return $openid;
}
/**
* 小程序 AppID(在线支付下单 appid 兜底等场景复用)
*/
public function appid(): string
{
return $this->config('mini_appid');
}
/**
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wechat.' . $key, '');
if ($value === null || $value === '') {
$value = match ($key) {
'mini_appid' => config('services.wechat.mini.appid', ''),
'mini_secret' => config('services.wechat.mini.secret', ''),
default => '',
};
}
return trim((string) $value);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 微信公众号服务(网页授权 code 换 openid,公众号 JSAPI 支付前置)
*
* H5 页面在微信内置浏览器打开 → 网页授权(snsapi_base)回调带回 code
* → 本服务以 code 换付款人 openid(公众号维度,与小程序 openid 不同,不可混用)
*/
class WechatMpService
{
/**
* 网页授权 code 换 openidsns/oauth2/access_token
*
* @return string 用户公众号维度 openid
* @throws RepositoryException 未配置公众号或授权失败
*/
public function code2openid(string $code): string
{
$appid = $this->appid();
$secret = $this->config('mp_secret');
if ($appid === '' || $secret === '') {
throw new RepositoryException('微信公众号未配置 AppID/Secret,请联系管理员');
}
$result = Http::timeout(10)->withOptions([
'verify' => false, // 禁用 SSL 证书验证
])->get('https://api.weixin.qq.com/sns/oauth2/access_token', [
'appid' => $appid,
'secret' => $secret,
'code' => $code,
'grant_type' => 'authorization_code',
])->json();
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
if ($openid === '') {
Log::warning('微信网页授权失败', ['response' => $result]);
throw new RepositoryException('微信网页授权失败:' . ($result['errmsg'] ?? '请重新授权'));
}
return $openid;
}
/**
* 公众号 AppID(公众号 JSAPI 下单 appid、H5 拼网页授权链接使用)
*/
public function appid(): string
{
return $this->config('mp_appid');
}
/**
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wechat.' . $key, '');
if ($value === null || $value === '') {
$value = match ($key) {
'mp_appid' => config('services.wechat.mp.appid', ''),
'mp_secret' => config('services.wechat.mp.secret', ''),
default => '',
};
}
return trim((string) $value);
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use EasyWeChat\Pay\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 微信官方支付服务(APIv3 小程序 JSAPI,基于 EasyWeChat Pay
*
* - 统一下单:POST /v3/pay/transactions/jsapi → prepay_id → 商户私钥签名生成 wx.requestPayment 调起参数
* - 交易查询:GET /v3/pay/transactions/out-trade-no/{商户订单号}
* - 支付通知:Wechatpay-Signature 验签(配置平台证书/公钥时)+ APIv3 密钥 AES-256-GCM 解密报文
*
* 配置优先级:后台「系统设置 → 微信官方支付」(site_config wxpay.*) > config/services.phpenv
*/
class WechatPayService
{
/** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */
public const string NOTIFY_ACK_OK = 'SUCCESS';
public const string NOTIFY_ACK_FAIL = 'FAIL';
/** 交易状态:支付成功(交易查询 / 支付通知的 trade_state */
public const string TRADE_STATE_SUCCESS = 'SUCCESS';
protected ?Application $application = null;
/**
* 统一下单(JSAPI:小程序 / 公众号 H5 通用)
*
* @param string $paymentNo 商户订单号(本系统支付单号)
* @param string $amountYuan 金额(元,两位小数)
* @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid
* @param string $description 订单标题
* @param string $appid 下单应用 appid(公众号 JSAPI 传公众号 appid;留空取配置的小程序 appid)
* @return array<string, mixed> 调起支付参数(appId/timeStamp/nonceStr/package/signType/paySign
* 小程序喂 wx.requestPayment,公众号 H5 喂 WeixinJSBridge getBrandWCPayRequest,结构同构)
* @throws RepositoryException 未配置或下单失败
*/
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description, string $appid = ''): array
{
$appid = trim($appid) !== '' ? trim($appid) : $this->appid();
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
'appid' => $appid,
'mchid' => $this->config('mch_id'),
'description' => mb_substr($description, 0, 127),
'out_trade_no' => $paymentNo,
'notify_url' => $this->notifyUrl(),
'amount' => [
'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分
'currency' => 'CNY',
],
'payer' => ['openid' => $openid],
], '下单');
$prepayId = (string) ($result['prepay_id'] ?? '');
if ($prepayId === '') {
Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]);
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
}
// buildMiniAppConfig 即 buildBridgeConfig(小程序 wx.requestPayment 与公众号 WeixinJSBridge 参数同构)
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $appid);
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId, 'appid' => $appid]);
return $params;
}
/**
* 交易查询(按商户订单号)
*
* @param string $paymentNo 商户订单号(本系统支付单号)
* @return array<string, mixed> 微信交易报文(trade_state/transaction_id/success_time/amount.total 等)
* @throws RepositoryException 查询失败
*/
public function queryOrder(string $paymentNo): array
{
return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [
'mchid' => $this->config('mch_id'),
], '查询');
}
/**
* 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密)
*
* @return array<string, mixed> 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等)
* @throws RepositoryException 验签或解密失败
*/
public function decryptNotify(Request $request): array
{
Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]);
$app = $this->app();
$server = $app->getServer();
$server->setRequestFromSymfonyRequest($request);
// 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有)
if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') {
try {
$app->getValidator()->validate($server->getRequest());
} catch (Throwable $e) {
throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage());
}
}
try {
$message = $server->getRequestMessage();
} catch (Throwable $e) {
throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage());
}
$params = $message->toArray();
Log::driver('pay')->info('微信支付通知解密', ['params' => $params]);
return $params;
}
/**
* 支付结果后台通知地址(统一下单时上送的 notify_url
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify';
}
/**
* 注入 EasyWeChat Application(测试 mock 用)
*/
public function setApplication(Application $application): static
{
$this->application = $application;
return $this;
}
/**
* EasyWeChat Pay Application(按当前配置懒加载构建)
*
* @throws RepositoryException 必填配置缺失或密钥无法解析
*/
public function app(): Application
{
if ($this->application !== null) {
return $this->application;
}
$mchId = $this->config('mch_id');
if ($mchId === '') {
throw new RepositoryException('微信支付未配置商户号,请联系管理员');
}
$platformCerts = [];
$platformCert = $this->config('platform_cert');
$platformSerial = $this->config('platform_serial');
if ($platformCert !== '' && $platformSerial !== '') {
$platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE');
}
try {
$this->application = new Application([
'mch_id' => $mchId,
'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'),
'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'),
'secret_key' => $this->config('secret_key'),
'platform_certs' => $platformCerts,
]);
} catch (RepositoryException $e) {
throw $e;
} catch (Throwable $e) {
throw new RepositoryException('微信支付配置错误:' . $e->getMessage());
}
return $this->application;
}
/**
* 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException
*
* @param array<string, mixed> $payload POST 时为 JSON 报文,GET 时为 query 参数
* @return array<string, mixed> 应答报文
* @throws RepositoryException
*/
protected function call(string $method, string $uri, array $payload, string $action): array
{
try {
$response = $method === 'GET'
? $this->app()->getClient()->get($uri, ['query' => $payload])
: $this->app()->getClient()->postJson($uri, $payload);
} catch (RepositoryException $e) {
throw $e;
} catch (Throwable $e) {
Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]);
throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试');
}
$body = $response->getContent(false);
Log::driver('pay')->info('微信支付' . $action . '应答', [
'uri' => $uri,
'status' => $response->getStatusCode(),
'body' => $body,
]);
$result = json_decode($body, true);
if ($response->isFailed()) {
$message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : '';
throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误'));
}
return is_array($result) ? $result : [];
}
/**
* 下单小程序 appid(默认取小程序自身 appid
*/
protected function appid(): string
{
$appid = $this->config('appid');
if ($appid === '') {
$appid = (string) site_config('wechat.mini_appid', '');
}
if ($appid === '') {
$appid = (string) config('services.wechat.mini.appid', '');
}
return $appid;
}
/**
* 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径
*
* @throws RepositoryException 未配置
*/
protected function pemContent(string $value, string $kind): string
{
$value = trim($value);
if ($value === '') {
$name = $kind === 'PRIVATE' ? '商户API私钥' : '证书';
throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员');
}
if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) {
return $value;
}
if (is_file($value)) {
return 'file://' . $value;
}
// base64 单行内容包装为 PEM
$header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE';
return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----";
}
/**
* 读取支付配置:后台站点配置优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wxpay.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wxpay.' . $key, '');
}
return trim((string) $value);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Services;
/**
* 参考重量估算:订货量 × 单品规格折算(斤),仅作参考,实际称重以人工录入为准
*/
class WeightEstimator
{
/**
* 估算参考重量(斤,3 位小数)
*
* 计价单位本身是重量单位时订货量即重量;否则从规格(如「10斤/箱」「500g/袋」)
* 解析每件重量再乘订货量;规格无法解析时计 0
*/
public static function estimate(string $spec, string $unit, string $quantity): string
{
return bcmul($quantity === '' ? '0' : $quantity, self::perUnitJin($spec, $unit), 3);
}
/**
* 单个计价单位折算重量(斤)
*/
private static function perUnitJin(string $spec, string $unit): string
{
// 按重量计价的单位:订货量本身就是重量
$unitWeight = self::toJin('1', $unit);
if ($unitWeight !== null) {
return $unitWeight;
}
// 从规格解析每件重量(如「10斤/箱」「500g/袋」「1.5kg/箱」)
if (preg_match('/(\d+(?:\.\d+)?)\s*(公斤|千克|kg|克|斤|g)/iu', $spec, $matches) === 1) {
$parsed = self::toJin($matches[1], $matches[2]);
if ($parsed !== null) {
return $parsed;
}
}
// 规格中的裸数字按斤计(如「10/箱」);无法解析计 0
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1) {
return $matches[0];
}
return '0';
}
/**
* 数值按单位折算为斤;非重量单位返回 null(1斤=500g1公斤/千克/kg=2斤)
*/
private static function toJin(string $value, string $unit): ?string
{
return match (mb_strtolower(trim($unit))) {
'斤' => $value,
'公斤', '千克', 'kg' => bcmul($value, '2', 3),
'克', 'g' => bcdiv($value, '500', 3),
default => null,
};
}
}
+2 -1
View File
@@ -17,7 +17,8 @@
"laravel/tinker": "^3.0",
"maatwebsite/excel": "^3.1",
"predis/predis": "2.0",
"w7corp/easywechat": "^6.19"
"w7corp/easywechat": "^6.19",
"ext-openssl": "*"
},
"require-dev": {
"laravel/boost": "^2.0",
+6
View File
@@ -133,6 +133,12 @@ return [
'level' => 'debug',
],
'pay' => [
'driver' => 'single',
'path' => storage_path('logs/pay.log'),
'level' => 'debug',
],
],
];
+47
View File
@@ -45,11 +45,58 @@ return [
/*
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
* 公众号(mp):网页授权换 openid、公众号 JSAPI 支付使用,后台 site_config wechat.mp_appid / wechat.mp_secret 优先
*/
'wechat' => [
'mini' => [
'appid' => env('WECHAT_MINI_APPID', ''),
'secret' => env('WECHAT_MINI_SECRET', ''),
],
'mp' => [
'appid' => env('WECHAT_MP_APPID', ''),
'secret' => env('WECHAT_MP_SECRET', ''),
],
],
/*
* 在线支付渠道开关(二选一):wangpu=旺铺支付网关 / wechat=微信官方支付
* 优先读取后台「系统设置 → 支付配置」(site_config pay.online_channel),为空时回退到这里的 env 配置
*/
'pay' => [
'online_channel' => env('PAY_ONLINE_CHANNEL', 'wangpu'),
],
/*
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
* 优先读取后台「系统设置 → 旺铺支付」(site_config wangpu.*),为空时回退到这里的 env 配置
*/
'wangpu' => [
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
'organiz_no' => env('WANGPU_ORGANIZ_NO', ''), // 合作机构渠道号
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(小程序主扫必填)
'mp_payway_code' => env('WANGPU_MP_PAYWAY_CODE', ''), // 公众号场景支付方式代码(留空回退 payway_code
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 支付/退款后台通知 MD5 专用加签Key(旺铺分配)
],
/*
* 微信官方支付(APIv3 小程序 JSAPI,基于 EasyWeChat Pay
* 优先读取后台「系统设置 → 微信官方支付」(site_config wxpay.*),为空时回退到这里的 env 配置
* 密钥/证书支持:完整 PEM 文本、base64 单行、或以 file:// 开头的文件路径
*/
'wxpay' => [
'mch_id' => env('WXPAY_MCH_ID', ''), // 微信支付商户号
'appid' => env('WXPAY_APPID', ''), // 下单小程序 appid(留空取小程序自身 appid
'private_key' => env('WXPAY_PRIVATE_KEY', ''), // 商户 API 私钥(apiclient_key.pem
'certificate' => env('WXPAY_CERTIFICATE', ''), // 商户 API 证书(apiclient_cert.pem,请求签名序列号取自证书)
'secret_key' => env('WXPAY_SECRET_KEY', ''), // APIv3 密钥(回调报文 AES-256-GCM 解密)
'platform_cert' => env('WXPAY_PLATFORM_CERT', ''), // 微信支付平台证书/微信支付公钥(回调验签,可空)
'platform_serial' => env('WXPAY_PLATFORM_SERIAL', ''), // 平台证书序列号/微信支付公钥ID(配合 platform_cert
],
];
@@ -27,6 +27,7 @@ class ProductModelFactory extends Factory
return [
'category_id' => 0,
'supplier_id' => 0,
'market' => '',
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
'spec' => self::SPECS[$seq % count(self::SPECS)],
'unit' => self::UNITS[$seq % count(self::UNITS)],
@@ -17,7 +17,7 @@ return new class extends Migration
$table->string('key', 50)->comment('设置项标示');
$table->string('title', 50)->comment('设置标题');
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
$table->string('values', 255)->nullable()->default('')->comment('设置值');
$table->string('values', 2000)->nullable()->default('')->comment('设置值');
$table->string('type', 50)->comment('设置类型');
$table->string('options', 500)->nullable()->comment('options配置');
$table->string('props', 500)->nullable()->comment('props配置');
@@ -42,6 +42,8 @@ return new class extends Migration
$table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
$table->string('mp_openid', 64)->default('')->comment('微信公众号 openid(公众号 JSAPI 支付付款人标识,网页授权换取后绑定)');
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->nullable()->default('')->comment('备注');
@@ -32,17 +32,19 @@ return new class extends Migration
$table->increments('id')->comment('商品ID');
$table->integer('category_id')->default(0)->comment('商品分类ID');
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
$table->string('market', 50)->default('')->nullable()->comment('市场(如:新发地、岳各庄)');
$table->string('name', 100)->comment('品名');
$table->string('spec', 100)->default('')->comment('规格/包规');
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
$table->string('price_unit', 20)->nullable()->default('元/斤')->comment('单价单位(如:元/斤、元/箱)');
$table->string('image_ids', 255)->default('')->comment('商品图片');
$table->text('content')->comment('商品图文详情');
$table->integer('sort')->default(0)->comment('排序');
$table->integer('shelf_life')->default(0)->comment('保质期');
$table->integer('shelf_life')->default(0)->nullable()->comment('保质期');
$table->integer('stock')->default(0)->comment('库存');
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)');
$table->string('remark', 255)->default('')->comment('备注');
$table->string('remark', 255)->nullable()->default('')->comment('备注');
$table->timestamps();
$table->softDeletes();
$table->index(['category_id', 'status'], 'product_category_status_index');
@@ -25,7 +25,7 @@ return new class extends Migration
$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已完成 9已取消)');
$table->string('remark', 255)->default('')->comment('订单备注');
$table->string('remark', 255)->default('')->nullable()->comment('订单备注');
$table->softDeletes();
$table->timestamps();
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
@@ -49,6 +49,7 @@ return new class extends Migration
$table->string('product_spec', 100)->default('')->comment('规格/包规');
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
$table->decimal('price', 10, 2)->default(0)->comment('单价');
$table->string('price_unit', 20)->default('')->comment('单价单位');
$table->string('image_ids', 255)->default('')->comment('商品图片');
$table->text('content')->comment('商品图文详情');
$table->integer('shelf_life')->default(0)->comment('保质期');
@@ -24,7 +24,7 @@ return new class extends Migration
$table->decimal('estimate_amount', 10, 2)->default(0)->comment('预估金额(按订货汇总)');
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额');
$table->integer('operator_id')->default(0)->comment('采购员(系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->string('remark', 255)->default('')->nullable()->comment('备注');
$table->timestamps();
$table->index(['purchase_date', 'status'], 'purchase_order_date_status_index');
$table->comment('采购单表');
@@ -28,14 +28,15 @@ return new class extends Migration
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额');
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额');
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
$table->integer('payment_id')->default(0)->comment('关联支付记录ID0=未发起支付)');
$table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)');
$table->string('pay_remark', 255)->default('')->comment('付款备注(线下收款信息)');
$table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)');
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->string('remark', 255)->nullable()->default('')->comment('备注(生成账单时按门店填写)');
$table->decimal('after_sale', 10, 2)->default(0)->comment('售后金额(生成账单时按门店填写,可正负,计入总金额)');
$table->timestamps();
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
@@ -15,19 +15,26 @@ return new class extends Migration
if (! Schema::hasTable('payment')) {
Schema::create('payment', function (Blueprint $table) {
$table->increments('id')->comment('支付记录ID');
$table->string('payment_no', 32)->unique()->comment('支付单号');
$table->string('payment_no', 32)->unique()->comment('支付单号(在线支付时作为商户订单号 mer_order_id 上送网关)');
$table->integer('store_id')->comment('门店ID(提交门店即支付人)');
$table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)');
$table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款');
$table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔');
$table->integer('status')->default(0)->comment('状态(0待审核 1已通过 2已拒绝');
$table->integer('pay_type')->default(1)->comment('支付类型(1线下凭证支付 2旺铺在线支付');
$table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款 4旺铺在线支付');
$table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔,线下凭证支付');
$table->integer('status')->default(0)->comment('状态(0待审核/待支付 1已通过/支付成功 2已拒绝/支付失败)');
$table->string('order_id', 64)->default('')->comment('旺铺平台订单号(网关返回)');
$table->string('trade_no', 64)->default('')->comment('通道交易流水号(支付成功返回)');
$table->string('openid', 64)->default('')->comment('付款人微信 openid(小程序在线支付)');
$table->timestamp('paid_at')->nullable()->comment('支付成功时间(网关交易时间)');
$table->text('pay_params')->nullable()->comment('旺铺下单返回的调起支付参数(JSON 快照)');
$table->string('remark', 255)->default('')->comment('门店备注');
$table->timestamp('audited_at')->nullable()->comment('审核时间');
$table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID');
$table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)');
$table->timestamps();
$table->index(['store_id', 'status'], 'payment_store_status_index');
$table->comment('支付记录表(小程序合并付款,后台审核汇款凭证)');
$table->index(['pay_type', 'status'], 'payment_type_status_index');
$table->comment('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
});
}
}
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 小程序首页特价推荐:后台批量选择商品标记为推荐,小程序端按推荐排序展示
*/
public function up(): void
{
if (! Schema::hasTable('mini_home_special')) {
Schema::create('mini_home_special', function (Blueprint $table) {
$table->increments('id')->comment('推荐ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->timestamps();
$table->unique('product_id', 'uk_mini_home_special_product');
$table->index(['status', 'sort'], 'idx_mini_home_special_status_sort');
$table->comment('小程序首页特价推荐商品');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('mini_home_special');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 采购单单品对账标记:商品明细「已对账」勾选入库持久化(替代原前端 localStorage 本地标记)
*/
public function up(): void
{
if (! Schema::hasTable('purchase_item_check')) {
Schema::create('purchase_item_check', function (Blueprint $table) {
$table->increments('id')->comment('标记ID');
$table->integer('purchase_id')->comment('采购单ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('operator_id')->default(0)->comment('标记人(后台系统用户ID');
$table->timestamps();
$table->unique(['purchase_id', 'product_id'], 'purchase_item_check_unique');
$table->comment('采购单单品对账标记表(商品明细「已对账」勾选)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_item_check');
}
};
+14
View File
@@ -89,6 +89,8 @@ class PermissionSeeder extends Seeder
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
['type' => 'rule', 'key' => 'product.goods.import', 'name' => '导入'],
['type' => 'rule', 'key' => 'product.goods.export', 'name' => '导出'],
],
],
[
@@ -269,6 +271,18 @@ class PermissionSeeder extends Seeder
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
],
],
[
'type' => 'route',
'key' => 'client.special',
'name' => '特价推荐',
'path' => '/client/special',
'children' => [
['type' => 'rule', 'key' => 'client.special.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'client.special.create', 'name' => '新增'],
['type' => 'rule', 'key' => 'client.special.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'client.special.delete', 'name' => '删除'],
],
],
],
],
[
+34 -1
View File
@@ -17,7 +17,10 @@ class SysDataSeeder extends Seeder
DB::table('sys_site_config_group')->insert([
['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date],
['id' => 3, 'title' => '业务配置', 'key' => 'services', 'remark' => '业务附加配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '网站的支付配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '收款方式与在线支付渠道配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 5, 'title' => '旺铺支付', 'key' => 'wangpu', 'remark' => '旺铺支付网关配置(进件入网后由旺铺分配)', 'created_at' => $date, 'updated_at' => $date],
['id' => 6, 'title' => '微信官方支付', 'key' => 'wxpay', 'remark' => '微信支付 APIv3 商户配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 7, 'title' => '微信应用配置', 'key' => 'wechat', 'remark' => '微信小程序/公众号应用凭证配置', 'created_at' => $date, 'updated_at' => $date],
]);
DB::table('sys_site_config_items')->insert([
['id' => 1, 'group_id' => 1, 'key' => 'title', 'title' => '网站标题', 'describe' => '网站标题,用于展示在网站logo旁边和登录页面以及网页title中', 'values' => 'Xin Admin', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date,],
@@ -26,9 +29,39 @@ class SysDataSeeder extends Seeder
['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 36, 'group_id' => 3, 'key' => 'order_time_start', 'title' => '下单开始时间', 'describe' => '允许下单的开始时间(24小时制 HH:mm,如 08:00),留空不限制;开始时间晚于截单时间表示跨天时段(如 20:00 至次日 06:00', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 37, 'group_id' => 3, 'key' => 'order_time_end', 'title' => '截单时间', 'describe' => '下单截止时间(24小时制 HH:mm,如 18:00),不在下单时段内门店不能下单,留空不限制', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
// 支付配置(pay):收款方式与在线支付渠道
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 21, 'group_id' => 4, 'key' => 'online_channel', 'title' => '在线支付渠道', 'describe' => '小程序在线支付通道(二选一):旺铺支付网关 或 微信官方支付(APIv3),切换仅影响之后发起的支付单', 'values' => 'wangpu', 'type' => 'Radio','options' => "wangpu=旺铺支付\nwechat=微信官方支付", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 29, 'group_id' => 4, 'key' => 'pay_methods', 'title' => '支付方式开关', 'describe' => '小程序付款页启用的支付方式(勾选展示),接口 /mini/payment/config 返回 pay_methods 供小程序端控制展示', 'values' => '["wechat_qrcode","alipay_qrcode","bank","online"]', 'type' => 'Checkbox','options' => "wechat_qrcode=微信收款码\nalipay_qrcode=支付宝收款码\nbank=对公汇款\nonline=在线支付", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
// 旺铺支付(wangpu):旺铺支付网关
['id' => 12, 'group_id' => 5, 'key' => 'base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 13, 'group_id' => 5, 'key' => 'organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 14, 'group_id' => 5, 'key' => 'mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 15, 'group_id' => 5, 'key' => 'mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 16, 'group_id' => 5, 'key' => 'term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
['id' => 17, 'group_id' => 5, 'key' => 'public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appidsub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
['id' => 34, 'group_id' => 5, 'key' => 'mp_payway_code', 'title' => '旺铺公众号支付方式代码', 'describe' => '公众号 H5 场景支付方式代码 payway_code(如 WECHAT_JSPAY),留空则回退小程序支付方式代码', 'values' => 'WECHAT_JSPAY', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
['id' => 35, 'group_id' => 5, 'key' => 'sign_key', 'title' => '旺铺通知加签Key', 'describe' => '支付/退款后台通知 MD5 专用加签Key(旺铺分配),用于校验通知报文签名 sign,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
// 微信官方支付(wxpay):微信支付 APIv3
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 23, 'group_id' => 6, 'key' => 'appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 24, 'group_id' => 6, 'key' => 'private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 25, 'group_id' => 6, 'key' => 'certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 26, 'group_id' => 6, 'key' => 'secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
['id' => 27, 'group_id' => 6, 'key' => 'platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准)', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 28, 'group_id' => 6, 'key' => 'platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
// 微信应用配置(wechat):小程序与公众号应用凭证
['id' => 30, 'group_id' => 7, 'key' => 'mini_appid', 'title' => '小程序AppID', 'describe' => '微信小程序 AppIDwx.login 换 openid、在线支付下单使用),留空回退 env WECHAT_MINI_APPID', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 31, 'group_id' => 7, 'key' => 'mini_secret', 'title' => '小程序AppSecret', 'describe' => '微信小程序 AppSecretcode2session 登录凭证校验),请勿泄露,留空回退 env WECHAT_MINI_SECRET', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 32, 'group_id' => 7, 'key' => 'mp_appid', 'title' => '公众号AppID', 'describe' => '微信公众号 AppID(公众号消息/网页授权等扩展能力使用)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 33, 'group_id' => 7, 'key' => 'mp_secret', 'title' => '公众号AppSecret', 'describe' => '微信公众号 AppSecret,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
]);
// 字典类型初始数据
DB::table('sys_dict')->insert([
@@ -101,8 +101,8 @@ class SysSiteConfigItemsController extends BaseController
SysSiteConfigService::refreshSiteConfig();
return $this->success();
}
$message = '设置保存失败' . $result['message'];
return $this->error($message);
$message = '设置保存失败';
return $this->error($result['errors'], $message);
}
/** 刷新设置 */
+1
View File
@@ -0,0 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./button-BILozH6U.js";import{t as r}from"./card-DU2T2-YW.js";import{t as i}from"./result-D_u5wtF_.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
@@ -0,0 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./button-BILozH6U.js";import{t as r}from"./card-DU2T2-YW.js";import{t as i}from"./result-D_u5wtF_.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
@@ -0,0 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./button-BILozH6U.js";import{t as r}from"./card-DU2T2-YW.js";import{t as i}from"./result-D_u5wtF_.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};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M862 465.3h-81c-4.6 0-9 2-12.1 5.5L550 723.1V160c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v563.1L255.1 470.8c-3-3.5-7.4-5.5-12.1-5.5h-81c-6.8 0-10.5 8.1-6 13.2L487.9 861a31.96 31.96 0 0048.3 0L868 478.5c4.5-5.2.8-13.2-6-13.2z`}}]},name:`arrow-down`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M868 545.5L536.1 163a31.96 31.96 0 00-48.3 0L156 545.5a7.97 7.97 0 006 13.2h81c4.6 0 9-2 12.1-5.5L474 300.9V864c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V300.9l218.9 252.3c3 3.5 7.4 5.5 12.1 5.5h81c6.8 0 10.5-8 6-13.2z`}}]},name:`arrow-up`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+1
View File
@@ -0,0 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./user-LBumqaa7.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),[n,e])?(0,a.jsx)(a.Fragment,{children:t}):null};export{o as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M840.4 300H183.6c-19.7 0-30.7 20.8-18.5 35l328.4 380.8c9.4 10.9 27.5 10.9 37 0L858.9 335c12.2-14.2 1.2-35-18.5-35z`}}]},name:`caret-down`,theme:`filled`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M699 353h-46.9c-10.2 0-19.9 4.9-25.9 13.3L469 584.3l-71.2-98.8c-6-8.3-15.6-13.3-25.9-13.3H325c-6.5 0-10.3 7.4-6.5 12.7l124.6 172.8a31.8 31.8 0 0051.7 0l210.6-292c3.9-5.3.1-12.7-6.4-12.7z`}},{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 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}}]},name:`check-circle`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M682 455V311l-76 76v68c-.1 50.7-42 92.1-94 92a95.8 95.8 0 01-52-15l-54 55c29.1 22.4 65.9 36 106 36 93.8 0 170-75.1 170-168z`}},{tag:`path`,attrs:{d:`M833 446h-60c-4.4 0-8 3.6-8 8 0 140.3-113.7 254-254 254-63 0-120.7-23-165-61l-54 54a334.01 334.01 0 00179 81v102H326c-13.9 0-24.9 14.3-25 32v36c.1 4.4 2.9 8 6 8h408c3.2 0 6-3.6 6-8v-36c0-17.7-11-32-25-32H547V782c165.3-17.9 294-157.9 294-328 0-4.4-3.6-8-8-8zm13.1-377.7l-43.5-41.9a8 8 0 00-11.2.1l-129 129C634.3 101.2 577 64 511 64c-93.9 0-170 75.3-170 168v224c0 6.7.4 13.3 1.2 19.8l-68 68A252.33 252.33 0 01258 454c-.2-4.4-3.8-8-8-8h-60c-4.4 0-8 3.6-8 8 0 53 12.5 103 34.6 147.4l-137 137a8.03 8.03 0 000 11.3l42.7 42.7c3.1 3.1 8.2 3.1 11.3 0L846.2 79.8l.1-.1c3.1-3.2 3-8.3-.2-11.4zM417 401V232c0-50.6 41.9-92 94-92 46 0 84.1 32.3 92.3 74.7L417 401z`}}]},name:`audio-muted`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default}))),l=e(t((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:`M842 454c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 140.3-113.7 254-254 254S258 594.3 258 454c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 168.7 126.6 307.9 290 327.6V884H326.7c-13.7 0-24.7 14.3-24.7 32v36c0 4.4 2.8 8 6.2 8h407.6c3.4 0 6.2-3.6 6.2-8v-36c0-17.7-11-32-24.7-32H548V782.1c165.3-18 294-158 294-328.1zM512 624c93.9 0 170-75.2 170-168V232c0-92.8-76.1-168-170-168s-170 75.2-170 168v224c0 92.8 76.1 168 170 168zm-94-392c0-50.6 41.9-92 94-92s94 41.4 94 92v224c0 50.6-41.9 92-94 92s-94-41.4-94-92V232z`}}]},name:`audio`,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(r,u({},e,{ref:t,icon:l.default}))),f=e(t((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:`M899.1 869.6l-53-305.6H864c14.4 0 26-11.6 26-26V346c0-14.4-11.6-26-26-26H618V138c0-14.4-11.6-26-26-26H432c-14.4 0-26 11.6-26 26v182H160c-14.4 0-26 11.6-26 26v192c0 14.4 11.6 26 26 26h17.9l-53 305.6a25.95 25.95 0 0025.6 30.4h723c1.5 0 3-.1 4.4-.4a25.88 25.88 0 0021.2-30zM204 390h272V182h72v208h272v104H204V390zm468 440V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H416V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H202.8l45.1-260H776l45.1 260H672z`}}]},name:`clear`,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(r,p({},e,{ref:t,icon:f.default})));export{d as n,c as r,m as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z`}}]},name:`clock-circle`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z`}}]},name:`close-circle`,theme:`outlined`}})),a=e(n()),o=e(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(r,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
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M505.7 661a8 8 0 0012.6 0l112-141.7c4.1-5.2.4-12.9-6.3-12.9h-74.1V168c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v338.3H400c-6.7 0-10.4 7.7-6.3 12.9l112 141.8zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z`}}]},name:`download`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M854.6 289.1a362.49 362.49 0 00-79.9-115.7 370.83 370.83 0 00-118.2-77.8C610.7 76.6 562.1 67 512 67c-50.1 0-98.7 9.6-144.5 28.5-44.3 18.3-84 44.5-118.2 77.8A363.6 363.6 0 00169.4 289c-19.5 45-29.4 92.8-29.4 142 0 70.6 16.9 140.9 50.1 208.7 26.7 54.5 64 107.6 111 158.1 80.3 86.2 164.5 138.9 188.4 153a43.9 43.9 0 0022.4 6.1c7.8 0 15.5-2 22.4-6.1 23.9-14.1 108.1-66.8 188.4-153 47-50.4 84.3-103.6 111-158.1C867.1 572 884 501.8 884 431.1c0-49.2-9.9-97-29.4-142zM512 880.2c-65.9-41.9-300-207.8-300-449.1 0-77.9 31.1-151.1 87.6-206.3C356.3 169.5 431.7 139 512 139s155.7 30.5 212.4 85.9C780.9 280 812 353.2 812 431.1c0 241.3-234.1 407.2-300 449.1zm0-617.2c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm79.2 255.2A111.6 111.6 0 01512 551c-29.9 0-58-11.7-79.2-32.8A111.6 111.6 0 01400 439c0-29.9 11.7-58 32.8-79.2C454 338.6 482.1 327 512 327c29.9 0 58 11.6 79.2 32.8C612.4 381 624 409.1 624 439c0 29.9-11.6 58-32.8 79.2z`}}]},name:`environment`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z`}}]},name:`eye`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M296 250c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296zm184 144H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8zm-48 458H208V148h560v320c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h264c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm440-88H728v-36.6c46.3-13.8 80-56.6 80-107.4 0-61.9-50.1-112-112-112s-112 50.1-112 112c0 50.7 33.7 93.6 80 107.4V764H520c-8.8 0-16 7.2-16 16v152c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V780c0-8.8-7.2-16-16-16zM646 620c0-27.6 22.4-50 50-50s50 22.4 50 50-22.4 50-50 50-50-22.4-50-50zm180 266H566v-60h260v60z`}}]},name:`audit`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default}))),l=e(t((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:`M880 912H144c-17.7 0-32-14.3-32-32V144c0-17.7 14.3-32 32-32h360c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8H184v656h656V520c0-4.4 3.6-8 8-8h56c4.4 0 8 3.6 8 8v360c0 17.7-14.3 32-32 32zM770.87 199.13l-52.2-52.2a8.01 8.01 0 014.7-13.6l179.4-21c5.1-.6 9.5 3.7 8.9 8.9l-21 179.4c-.8 6.6-8.9 9.4-13.6 4.7l-52.4-52.4-256.2 256.2a8.03 8.03 0 01-11.3 0l-42.4-42.4a8.03 8.03 0 010-11.3l256.1-256.3z`}}]},name:`export`,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(r,u({},e,{ref:t,icon:l.default}))),f=e(t((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:`M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zm-392 88c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296zm376 116c-119.3 0-216 96.7-216 216s96.7 216 216 216 216-96.7 216-216-96.7-216-216-216zm107.5 323.5C750.8 868.2 712.6 884 672 884s-78.8-15.8-107.5-44.5C535.8 810.8 520 772.6 520 732s15.8-78.8 44.5-107.5C593.2 595.8 631.4 580 672 580s78.8 15.8 107.5 44.5C808.2 653.2 824 691.4 824 732s-15.8 78.8-44.5 107.5zM761 656h-44.3c-2.6 0-5 1.2-6.5 3.3l-63.5 87.8-23.1-31.9a7.92 7.92 0 00-6.5-3.3H573c-6.5 0-10.3 7.4-6.5 12.7l73.8 102.1c3.2 4.4 9.7 4.4 12.9 0l114.2-158c3.9-5.3.1-12.7-6.4-12.7zM440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z`}}]},name:`file-done`,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(r,p({},e,{ref:t,icon:f.default})));export{d as n,c as r,m as t};
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z`}}]},name:`file`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9a127.5 127.5 0 0138.1 91v112.5c.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z`}}]},name:`github`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default}))),l=e(t((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:`M536.1 273H488c-4.4 0-8 3.6-8 8v275.3c0 2.6 1.2 5 3.3 6.5l165.3 120.7c3.6 2.6 8.6 1.9 11.2-1.7l28.6-39c2.7-3.7 1.9-8.7-1.7-11.2L544.1 528.5V281c0-4.4-3.6-8-8-8zm219.8 75.2l156.8 38.3c5 1.2 9.9-2.6 9.9-7.7l.8-161.5c0-6.7-7.7-10.5-12.9-6.3L752.9 334.1a8 8 0 003 14.1zm167.7 301.1l-56.7-19.5a8 8 0 00-10.1 4.8c-1.9 5.1-3.9 10.1-6 15.1-17.8 42.1-43.3 80-75.9 112.5a353 353 0 01-112.5 75.9 352.18 352.18 0 01-137.7 27.8c-47.8 0-94.1-9.3-137.7-27.8a353 353 0 01-112.5-75.9c-32.5-32.5-58-70.4-75.9-112.5A353.44 353.44 0 01171 512c0-47.8 9.3-94.2 27.8-137.8 17.8-42.1 43.3-80 75.9-112.5a353 353 0 01112.5-75.9C430.6 167.3 477 158 524.8 158s94.1 9.3 137.7 27.8A353 353 0 01775 261.7c10.2 10.3 19.8 21 28.6 32.3l59.8-46.8C784.7 146.6 662.2 81.9 524.6 82 285 82.1 92.6 276.7 95 516.4 97.4 751.9 288.9 942 524.8 942c185.5 0 343.5-117.6 403.7-282.3 1.5-4.2-.7-8.9-4.9-10.4z`}}]},name:`history`,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(r,u({},e,{ref:t,icon:l.default}))),f=e(t((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 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M464 336a48 48 0 1096 0 48 48 0 10-96 0zm72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8z`}}]},name:`info-circle`,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(r,p({},e,{ref:t,icon:f.default})));export{d as n,c as r,m as t};
+1
View File
@@ -0,0 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,Q as n,St as r,Vn as i,Z as a,at as o,lr as ee,p as te,u as ne,xt as s,yt as c,zn as l}from"./jsx-runtime-CRBytmvs.js";import{f as u,m as d,p as f}from"./tooltip-SaeG1Uv7.js";import{a as p,o as m}from"./style-BmYds38x.js";import{r as re,t as ie}from"./es-DGcDZmXr.js";var h=e(t());function g(e,t){let n=(0,h.useRef)([]),r=()=>{n.current.push(setTimeout(()=>{e.current?.input&&e.current?.input.getAttribute(`type`)===`password`&&e.current?.input.hasAttribute(`value`)&&e.current?.input.removeAttribute(`value`)}))};return(0,h.useEffect)(()=>(t&&r(),()=>n.current.forEach(e=>{e&&clearTimeout(e)})),[t]),r}function ae(e){return!!(e.prefix||e.suffix||e.allowClear||e.showCount)}var _=(0,h.forwardRef)((e,t)=>{let{prefixCls:_,bordered:oe=!0,status:se,size:ce,disabled:le,onBlur:ue,onFocus:v,suffix:y,allowClear:b,addonAfter:x,addonBefore:S,className:C,style:w,styles:T,rootClassName:E,onChange:D,classNames:O,variant:k,...A}=e,{getPrefixCls:j,direction:M,allowClear:N,autoComplete:P,className:F,style:I,classNames:L,styles:R}=l(`input`),z=j(`input`,_),B=(0,h.useRef)(null),V=c(z),[H,U]=m(z,E);p(z,V);let{compactSize:de,compactItemClassnames:fe}=a(z,M),W=n(e=>ce??de??e),pe=h.useContext(o),G=le??pe,me={...e,size:W,disabled:G},he=r(I),ge=r(w),[K,q]=s([L,O],[R,he,T,ge],{props:me}),{status:_e,hasFeedback:J,feedbackIcon:ve}=(0,h.useContext)(te),Y=u(_e,se);(0,h.useRef)(ae(e)||!!J);let X=g(B,!0),Z=e=>{X(),ue?.(e)},ye=e=>{X(),v?.(e)},be=e=>{X(),D?.(e)},xe=(J||y)&&h.createElement(h.Fragment,null,y,J&&ve),Se=re({allowClear:b,contextAllowClear:N,componentName:`Input`}),[Q,$]=ne(`input`,k,oe);return h.createElement(ie,{ref:ee(t,B),prefixCls:z,autoComplete:P,...A,disabled:G,onBlur:Z,onFocus:ye,style:q.root,styles:q,suffix:xe,allowClear:Se,className:i(C,E,U,V,fe,F,K.root),onChange:be,addonBefore:S&&h.createElement(d,{form:!0,space:!0},S),addonAfter:x&&h.createElement(d,{form:!0,space:!0},x),classNames:{...K,input:i({[`${z}-sm`]:W===`small`,[`${z}-lg`]:W===`large`,[`${z}-rtl`]:M===`rtl`},K.input,H),variant:i({[`${z}-${Q}`]:$},f(z,Y)),affixWrapper:i({[`${z}-affix-wrapper-sm`]:W===`small`,[`${z}-affix-wrapper-lg`]:W===`large`,[`${z}-affix-wrapper-rtl`]:M===`rtl`},H),wrapper:i({[`${z}-group-rtl`]:M===`rtl`},H),groupWrapper:i({[`${z}-group-wrapper-sm`]:W===`small`,[`${z}-group-wrapper-lg`]:W===`large`,[`${z}-group-wrapper-rtl`]:M===`rtl`,[`${z}-group-wrapper-${Q}`]:$},f(`${z}-group-wrapper`,Y,J),H)}})});export{g as n,_ as t};
+4
View File
@@ -0,0 +1,4 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{An as n,Dr as r,Ft as i,J as a}from"./jsx-runtime-CRBytmvs.js";var o=new n(`antMoveDownIn`,{"0%":{transform:`translate3d(0, 100%, 0)`,transformOrigin:`0 0`,opacity:0},"100%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1}}),s=new n(`antMoveDownOut`,{"0%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1},"100%":{transform:`translate3d(0, 100%, 0)`,transformOrigin:`0 0`,opacity:0}}),c=new n(`antMoveLeftIn`,{"0%":{transform:`translate3d(-100%, 0, 0)`,transformOrigin:`0 0`,opacity:0},"100%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1}}),l=new n(`antMoveLeftOut`,{"0%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1},"100%":{transform:`translate3d(-100%, 0, 0)`,transformOrigin:`0 0`,opacity:0}}),u=new n(`antMoveRightIn`,{"0%":{transform:`translate3d(100%, 0, 0)`,transformOrigin:`0 0`,opacity:0},"100%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1}}),d=new n(`antMoveRightOut`,{"0%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1},"100%":{transform:`translate3d(100%, 0, 0)`,transformOrigin:`0 0`,opacity:0}}),f={"move-up":{inKeyframes:new n(`antMoveUpIn`,{"0%":{transform:`translate3d(0, -100%, 0)`,transformOrigin:`0 0`,opacity:0},"100%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1}}),outKeyframes:new n(`antMoveUpOut`,{"0%":{transform:`translate3d(0, 0, 0)`,transformOrigin:`0 0`,opacity:1},"100%":{transform:`translate3d(0, -100%, 0)`,transformOrigin:`0 0`,opacity:0}})},"move-down":{inKeyframes:o,outKeyframes:s},"move-left":{inKeyframes:c,outKeyframes:l},"move-right":{inKeyframes:u,outKeyframes:d}},p=(e,t)=>{let{antCls:n}=e,r=`${n}-${t}`,{inKeyframes:i,outKeyframes:o}=f[t];return[a(r,i,o,e.motionDurationMid),{[`
${r}-enter,
${r}-appear
`]:{opacity:0,animationTimingFunction:e.motionEaseOutCirc},[`${r}-leave`]:{animationTimingFunction:e.motionEaseInOutCirc}}]},m=t((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`}})),h=e(r()),g=e(m());function _(){return _=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},_.apply(this,arguments)}var v=h.forwardRef((e,t)=>h.createElement(i,_({},e,{ref:t,icon:g.default})));export{p as n,v as t};
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494zM504 618H320c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8zM312 490v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H320c-4.4 0-8 3.6-8 8z`}}]},name:`file-text`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default}))),l=e(t((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:`M574 665.4a8.03 8.03 0 00-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8a8.03 8.03 0 00-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zm258.6-474c-84.6-84.6-221.5-84.6-306 0L410.3 307.6a8.03 8.03 0 000 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6a8.03 8.03 0 000 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1zM610.1 372.3a8.03 8.03 0 00-11.3 0L372.3 598.7a8.03 8.03 0 000 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z`}}]},name:`link`,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(r,u({},e,{ref:t,icon:l.default})));export{c as n,d 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
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M820 436h-40c-4.4 0-8 3.6-8 8v40c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-40c0-4.4-3.6-8-8-8zm32-104H732V120c0-4.4-3.6-8-8-8H300c-4.4 0-8 3.6-8 8v212H172c-44.2 0-80 35.8-80 80v328c0 17.7 14.3 32 32 32h168v132c0 4.4 3.6 8 8 8h424c4.4 0 8-3.6 8-8V772h168c17.7 0 32-14.3 32-32V412c0-44.2-35.8-80-80-80zM360 180h304v152H360V180zm304 664H360V568h304v276zm200-140H732V500H292v204H160V412c0-6.6 5.4-12 12-12h680c6.6 0 12 5.4 12 12v292z`}}]},name:`printer`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+1
View File
@@ -0,0 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,Ln as n,yr as r}from"./jsx-runtime-CRBytmvs.js";import{t as i}from"./config-provider-CqLGIhNd.js";var a=e(t());function o(e){return t=>a.createElement(i,{theme:{token:{motion:!1,zIndexPopupBase:0}}},a.createElement(e,{...t}))}var s=(e,t,i,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,_]=r(!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};i&&(b=i(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};
+1
View File
@@ -0,0 +1 @@
import{i as e,t}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as n,Ft as r}from"./jsx-runtime-CRBytmvs.js";var i=t((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:`M824.8 613.2c-16-51.4-34.4-94.6-62.7-165.3C766.5 262.2 689.3 112 511.5 112 331.7 112 256.2 265.2 261 447.9c-28.4 70.8-46.7 113.7-62.7 165.3-34 109.5-23 154.8-14.6 155.8 18 2.2 70.1-82.4 70.1-82.4 0 49 25.2 112.9 79.8 159-26.4 8.1-85.7 29.9-71.6 53.8 11.4 19.3 196.2 12.3 249.5 6.3 53.3 6 238.1 13 249.5-6.3 14.1-23.8-45.3-45.7-71.6-53.8 54.6-46.2 79.8-110.1 79.8-159 0 0 52.1 84.6 70.1 82.4 8.5-1.1 19.5-46.4-14.5-155.8z`}}]},name:`qq`,theme:`outlined`}})),a=e(n()),o=e(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(r,s({},e,{ref:t,icon:o.default})));export{c as t};

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