Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c986c86ed5 | |||
| 1370926780 |
File diff suppressed because one or more lines are too long
@@ -10,6 +10,9 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -18,6 +21,15 @@ 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 一级分类ID(0=全部)
|
||||
@@ -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,6 +132,8 @@ 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';
|
||||
@@ -131,24 +153,30 @@ class BillExport implements FromCollection, WithStyles
|
||||
$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) $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
|
||||
{
|
||||
@@ -168,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) {
|
||||
@@ -184,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 上溯取根分类(商品含软删除,保证历史账单可导出),
|
||||
* 仅保留根分类为所选分类的行
|
||||
|
||||
@@ -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 => 名称(导出列,升序)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,40 @@ 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),
|
||||
* 支持按供应商筛选;行尾合计 + 门店列合计;门店列整列填充突出颜色
|
||||
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -130,10 +148,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
||||
'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,
|
||||
@@ -185,6 +203,11 @@ 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'],
|
||||
@@ -194,12 +217,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
||||
$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++;
|
||||
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
|
||||
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||
@@ -215,12 +237,18 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
||||
);
|
||||
$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
|
||||
{
|
||||
@@ -232,16 +260,66 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
// 门店列整列(列头 → 合计行)填充突出颜色
|
||||
$storeCount = count($this->storeNames);
|
||||
for ($i = 0; $i < $storeCount; $i++) {
|
||||
$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(12);
|
||||
}
|
||||
|
||||
// 全部单元格水平/垂直居中
|
||||
$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);
|
||||
|
||||
// 金额类列(成本/单价/金额)货币格式:¥ + 两位小数
|
||||
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);
|
||||
$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);
|
||||
$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 = [];
|
||||
@@ -259,6 +337,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 处理;仅展示参考)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
@@ -64,6 +85,10 @@ 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'],
|
||||
@@ -75,7 +100,6 @@ 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);
|
||||
@@ -84,6 +108,8 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
||||
// 合计行
|
||||
$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);
|
||||
}
|
||||
@@ -94,7 +120,8 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计加粗,冻结列头
|
||||
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||
* 数量(浅黄)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
@@ -107,6 +134,47 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
||||
->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) {
|
||||
@@ -121,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ 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 DATA_FILL = 'FFFFFF99';
|
||||
/** 汇总列填充色(浅黄) */
|
||||
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||
|
||||
/** 门店数量列填充色(浅蓝) */
|
||||
private const string STORE_FILL = 'FFDDEBF7';
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
@@ -31,8 +36,14 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
/** 列头所在行索引 */
|
||||
private int $headerRow = 1;
|
||||
|
||||
/** @var array<int, array<int, int>> 有数据的单元格:行索引(1 起)=> 列索引(1 起)列表 */
|
||||
private array $dataCells = [];
|
||||
/** 数据末行索引 */
|
||||
private int $lastRow = 1;
|
||||
|
||||
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
|
||||
private array $rowData = [];
|
||||
|
||||
/** @var array{quantity: int, stores: array<int, int>} 合计行填色判断数据 */
|
||||
private array $summaryTotals = ['quantity' => 0, 'stores' => []];
|
||||
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
@@ -79,7 +90,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行(有数据的单元格记录到 dataCells:品名/汇总/门店数量)
|
||||
// 明细行(0 数量的门店格留空不填色)
|
||||
$totalQuantity = 0;
|
||||
$storeTotals = array_fill_keys($storeIds, 0);
|
||||
foreach ($this->productRows as $productRow) {
|
||||
@@ -88,17 +99,18 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
(int) $productRow['quantity'],
|
||||
$this->marketLabel,
|
||||
];
|
||||
$filled = [1, 2, 3];
|
||||
foreach ($storeIds as $position => $storeId) {
|
||||
$storeQuantities = [];
|
||||
foreach ($storeIds as $storeId) {
|
||||
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
|
||||
$line[] = $quantity > 0 ? $quantity : '';
|
||||
if ($quantity > 0) {
|
||||
$filled[] = 4 + $position;
|
||||
$storeTotals[$storeId] += $quantity;
|
||||
}
|
||||
$storeQuantities[$storeId] = $quantity;
|
||||
$storeTotals[$storeId] += $quantity;
|
||||
}
|
||||
$rows[] = $line;
|
||||
$this->dataCells[++$rowIndex] = $filled;
|
||||
$this->rowData[++$rowIndex] = [
|
||||
'quantity' => (int) $productRow['quantity'],
|
||||
'store_quantities' => $storeQuantities,
|
||||
];
|
||||
$totalQuantity += (int) $productRow['quantity'];
|
||||
}
|
||||
|
||||
@@ -108,6 +120,8 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
|
||||
);
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
$this->lastRow = $rowIndex;
|
||||
$this->summaryTotals = ['quantity' => $totalQuantity, 'stores' => $storeTotals];
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
@@ -118,7 +132,8 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计加粗,有数据的单元格填充突出颜色,冻结列头
|
||||
* 标题/列头/合计加粗;全表居中 + 全边框;
|
||||
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
@@ -130,19 +145,46 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
$storeCount = count($this->stores);
|
||||
for ($i = 0; $i < $storeCount; $i++) {
|
||||
$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(12);
|
||||
}
|
||||
|
||||
// 有数据的单元格:填充突出颜色并加粗(含品名/汇总/门店数量)
|
||||
foreach ($this->dataCells as $rowIndex => $columns) {
|
||||
foreach ($columns as $columnIndex) {
|
||||
$style = $sheet->getStyle(Coordinate::stringFromColumnIndex($columnIndex) . $rowIndex);
|
||||
$style->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB(self::DATA_FILL);
|
||||
$style->getFont()->setBold(true);
|
||||
// 全部单元格水平/垂直居中
|
||||
$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);
|
||||
|
||||
// 汇总列(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);
|
||||
}
|
||||
}
|
||||
if ($this->summaryTotals['quantity'] > 0) {
|
||||
$this->fillCell($sheet, 'B' . $this->lastRow, 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);
|
||||
}
|
||||
}
|
||||
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
|
||||
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +202,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
|
||||
return $styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格纯色填充
|
||||
*/
|
||||
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||
{
|
||||
$sheet->getStyle($coordinate)
|
||||
->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()
|
||||
->setARGB($argb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -15,11 +16,11 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(旺铺网关 JSAPI)
|
||||
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3)
|
||||
*
|
||||
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
|
||||
* → 小程序 wx.requestPayment 完成支付
|
||||
* → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账)
|
||||
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
||||
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
@@ -28,6 +29,7 @@ class OnlinePaymentController extends BaseMiniController
|
||||
public function __construct(
|
||||
protected OnlinePaymentService $onlinePayment,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatPayService $wxpay,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -123,6 +125,43 @@ class OnlinePaymentController extends BaseMiniController
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS)
|
||||
*/
|
||||
|
||||
@@ -13,34 +13,57 @@ use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 在线支付编排服务(旺铺网关)
|
||||
* 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付)
|
||||
*
|
||||
* 链路:门店小程序选择账单合并付款
|
||||
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2)
|
||||
* → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||
* → 按当前渠道(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';
|
||||
|
||||
public function __construct(
|
||||
protected BillNumberService $billNumber,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatPayService $wxpay,
|
||||
protected WechatMiniService $wechat,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 当前在线支付渠道(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() 返回的登录凭证
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
|
||||
{
|
||||
$channel = $this->channel();
|
||||
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
|
||||
|
||||
// 换取付款人 openid 并绑定到门店(下次可直接复用)
|
||||
$openid = $this->wechat->code2session($code);
|
||||
if ((string) $store->openid !== $openid) {
|
||||
@@ -48,7 +71,7 @@ class OnlinePaymentService
|
||||
$store->save();
|
||||
}
|
||||
|
||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark) {
|
||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
||||
$bills = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->whereIn('id', $billIds)
|
||||
@@ -79,7 +102,7 @@ class OnlinePaymentService
|
||||
'store_id' => $store->id,
|
||||
'amount' => $amount,
|
||||
'pay_type' => PaymentModel::TYPE_ONLINE,
|
||||
'pay_method' => PaymentModel::METHOD_WANGPU,
|
||||
'pay_method' => $payMethod,
|
||||
'voucher_ids' => '',
|
||||
'status' => PaymentModel::STATUS_PENDING,
|
||||
'openid' => $openid,
|
||||
@@ -93,21 +116,30 @@ class OnlinePaymentService
|
||||
});
|
||||
|
||||
try {
|
||||
$gatewayData = $this->wangpu->createOrder([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_amt' => (string) $payment->amount,
|
||||
'open_id' => $openid,
|
||||
'sub_appid' => $this->subAppid(),
|
||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||
]);
|
||||
$gatewayData = $channel === self::CHANNEL_WECHAT
|
||||
? $this->wxpay->createOrder(
|
||||
$payment->payment_no,
|
||||
(string) $payment->amount,
|
||||
$openid,
|
||||
'账单合并付款-' . $payment->payment_no,
|
||||
)
|
||||
: $this->wangpu->createOrder([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_amt' => (string) $payment->amount,
|
||||
'open_id' => $openid,
|
||||
'sub_appid' => $this->subAppid(),
|
||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// 网关下单失败:整笔作废并释放账单,门店可重新发起
|
||||
$this->discard($payment, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||
if ($channel === self::CHANNEL_WANGPU) {
|
||||
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||
}
|
||||
$payment->pay_params = $gatewayData;
|
||||
$payment->save();
|
||||
|
||||
@@ -115,7 +147,7 @@ class OnlinePaymentService
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
*
|
||||
* @param array<string, mixed> $params 通知报文(已验签)
|
||||
* @return bool 本次是否执行了结账(false = 重复通知)
|
||||
@@ -145,9 +177,46 @@ class OnlinePaymentService
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
|
||||
* 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账
|
||||
*
|
||||
* @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态
|
||||
* @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
|
||||
@@ -156,6 +225,10 @@ class OnlinePaymentService
|
||||
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);
|
||||
|
||||
@@ -172,6 +245,30 @@ class OnlinePaymentService
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
|
||||
*
|
||||
@@ -208,7 +305,7 @@ class OnlinePaymentService
|
||||
'status' => BillModel::STATUS_PAID,
|
||||
'paid_at' => $paidAt,
|
||||
'paid_operator_id' => 0,
|
||||
'pay_remark' => PaymentModel::METHOD_NAMES[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $payment->payment_no . ')',
|
||||
'pay_remark' => (PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $payment->payment_no . ')',
|
||||
]);
|
||||
|
||||
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
|
||||
@@ -307,4 +404,16 @@ class OnlinePaymentService
|
||||
}
|
||||
return trim($subAppid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信渠道商户号(用于通知商户号一致性校验)
|
||||
*/
|
||||
protected function wxpayMchId(): string
|
||||
{
|
||||
$mchId = (string) site_config('pay.wxpay_mch_id', '');
|
||||
if ($mchId === '') {
|
||||
$mchId = (string) config('services.wxpay.mch_id', '');
|
||||
}
|
||||
return trim($mchId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?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 pay.wxpay_*) > config/services.php(env)
|
||||
*/
|
||||
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)
|
||||
*
|
||||
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||
* @param string $amountYuan 金额(元,两位小数)
|
||||
* @param string $openid 付款人 openid
|
||||
* @param string $description 订单标题
|
||||
* @return array<string, mixed> wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign)
|
||||
* @throws RepositoryException 未配置或下单失败
|
||||
*/
|
||||
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description): array
|
||||
{
|
||||
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
||||
'appid' => $this->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');
|
||||
}
|
||||
|
||||
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid());
|
||||
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]);
|
||||
|
||||
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) 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.php(env)
|
||||
*/
|
||||
protected function config(string $key): string
|
||||
{
|
||||
$value = site_config('pay.wxpay_' . $key, '');
|
||||
if ($value === null || $value === '') {
|
||||
$value = config('services.wxpay.' . $key, '');
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* 在线支付渠道开关(二选一):wangpu=旺铺支付网关 / wechat=微信官方支付
|
||||
* 优先读取后台「系统设置 → 支付配置」(site_config pay.online_channel),为空时回退到这里的 env 配置
|
||||
*/
|
||||
'pay' => [
|
||||
'online_channel' => env('PAY_ONLINE_CHANNEL', 'wangpu'),
|
||||
],
|
||||
|
||||
/*
|
||||
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
|
||||
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置
|
||||
@@ -68,4 +76,19 @@ return [
|
||||
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
||||
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
|
||||
],
|
||||
|
||||
/*
|
||||
* 微信官方支付(APIv3 小程序 JSAPI,基于 EasyWeChat Pay)
|
||||
* 优先读取后台「系统设置 → 支付配置」(site_config pay.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)
|
||||
],
|
||||
];
|
||||
|
||||
@@ -38,6 +38,14 @@ class SysDataSeeder extends Seeder
|
||||
['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 19, 'group_id' => 4, 'key' => 'wangpu_payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 20, 'group_id' => 4, 'key' => 'wangpu_private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 11, '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' => 12, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 22, 'group_id' => 4, 'key' => 'wxpay_mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 13, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 23, 'group_id' => 4, 'key' => 'wxpay_appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 14, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 24, 'group_id' => 4, 'key' => 'wxpay_private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 15, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 25, 'group_id' => 4, 'key' => 'wxpay_certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 16, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 26, 'group_id' => 4, 'key' => 'wxpay_secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 17, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 27, 'group_id' => 4, 'key' => 'wxpay_platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准)', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 18, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 28, 'group_id' => 4, 'key' => 'wxpay_platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 19, 'created_at' => $date, 'updated_at' => $date],
|
||||
]);
|
||||
// 字典类型初始数据
|
||||
DB::table('sys_dict')->insert([
|
||||
|
||||
@@ -142,7 +142,7 @@ class ExportTest extends ProcurementTestCase
|
||||
{
|
||||
[$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers();
|
||||
// 无订货的上架商品也应出现在导出中
|
||||
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '7.00']);
|
||||
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '7.00', 'spec' => '10斤/箱']);
|
||||
|
||||
Excel::fake();
|
||||
$this->actingAsSysUser();
|
||||
@@ -170,10 +170,10 @@ class ExportTest extends ProcurementTestCase
|
||||
|| (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) {
|
||||
return false;
|
||||
}
|
||||
// 无订货商品行:数量 0、单价留空、门店列 0
|
||||
// 无订货商品行:数量 0、单价按成本价÷包规显示(7÷10=0.70)、门店列 0
|
||||
$extraRow = $rows->firstWhere(2, $extra->name);
|
||||
if ($extraRow === null
|
||||
|| (float) $extraRow[9] !== 0.0 || $extraRow[8] !== ''
|
||||
|| (float) $extraRow[9] !== 0.0 || (float) $extraRow[8] !== 0.7
|
||||
|| (float) $extraRow[12] !== 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Services\WechatPayService;
|
||||
use EasyWeChat\Kernel\Support\AesGcm;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(微信官方渠道 APIv3,EasyWeChat):下单 → 调起支付 → 后台通知/主动查询结账
|
||||
*
|
||||
* - Symfony MockHttpClient 模拟微信支付接口,不触网;商户私钥/证书为测试专用自签名(与生产无关)
|
||||
* - 通知报文以 APIv3 密钥 AES-256-GCM 加密构造,走真实解密链路
|
||||
* - 配置平台证书时走真实 Wechatpay-Signature 验签链路
|
||||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||||
*/
|
||||
class MiniWechatPaymentTest extends ProcurementTestCase
|
||||
{
|
||||
/** 测试专用商户 API 私钥(自签名,仅测试使用) */
|
||||
private const string TEST_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChGoUMvCaiaf97\nXcUExEA3VP4FVwUCPQf/GPvm2cjHZACcPj65rjKNs5nJ0xaKELrJkUCZ3+N5YJJ4\nVGBBhh9GXekrzFckeqUn1sRS9FFbWfNeqAvz3qgps+ohnriPMkTRTw/SONTVRuNV\nesKz8FTYbT5Vl6jSHv71CMMcm6qrhvZ9av1nB03MrUN06v2OMhfXrZTBV5KI5zvI\nOwxetOz0MTAHcfRyrW8cejiJo3baRsRRHXsnDRB7p4H1KIPx+N7yTJKv/L2+tgXY\nmhIjKJRC5hDmrLrLOzievqKlBTsBcG41UWzcLEDG96BcR6Le+dzlJIXXi2taoFXG\nFuDUVt/bAgMBAAECggEAEagPv57kt8AuQ/MO+MAZhExIWtrNWGV1Tua0v6RMIQOQ\nyDmk3kIn6ufYNF/HhJzjcTFThzRxKzMDr98aKD7u2+NxqV8yiHOjTi53mIU2YBtc\n/5l4qzhSmIojYEk+U6i1aiCOzKzLMWTHiELKvAsxrAFXvOxrkDvdbYrm92ey2Nbm\nnvFTzTlPNEVf8HwKIFpbbV/r5RlrqDOqj8np6t5X+PlpL4Kx+oO8XVaaWjjb+2NB\nPX7laZNLa2oJ+sY4qWnwPyCeZQu2glPtBjJQRWX8+eP5p7xMMWylosL056LHasb2\nNC+KDjXSA/4O19NhBwsX8Q7QDbuJrVt/2WMboJxfYQKBgQDU7U64gzdTFezTgJ18\nb4hXqvVyzksireABV2BfKiah25yMH5s4YtgFneusn0flh5yKOh1phbn9EmMGAnob\nyHYfcyiFFR1i+4j3ELblKTkbXeaMIthTqL9QRULc3HwVjKINs3tUAlpSHqdFrzAT\nQb8JcgHfmnDrqSNCNXLJY1cgLwKBgQDBsXj7PDX+URT4m7kMWD4GzTlAbAotLBO5\nGxugKAxczGbAk3G+xmYbwgbuKUHTzBAXX0atfCMjbK9DdowRlyVbI948o8drXqz2\nR2l6u3VyV0ERP+9BV0xfLNbiQGWiPZiYR9hYg+ewbzCmsQEyAUJGisxPZZtNz598\nmqhhQLeEFQKBgQCqTCJp8IiPKzn+7x8GJy4k79bfu57cXbSLXhb2BgBf9AWBDMZY\nkrWzyFp19e7K5WuOImzjuNDIV6xbYh/HmMzg2nnN9tVKFWO2NugQ4KeL+84oxrW+\nM3jP+pU/kBiuI4x46NP6nOcgRuQCF9ubizn/k+9rp1opIV3R1m24JHvKgQKBgFUd\nZtuIHvXtrXh9/bg2ArO8dR8hGuu0xcn+5onfb2dMDw8q+73oszAZeDAqAxpOPvf2\nTzmnJk3H0dCkhHKqZ6kAPwWItvYwuXLT+L8NJ1QikZ5B6SJeeVoNezQbNk4wISEx\n2Rk0hZibk9Z8S9ksgnI7RgLR1IhB54S69ake5kXtAoGANB/pN7MBP9ZeUBKq6eoB\nTHcHZjcD8ZRC/4rsgDcwpHd67xWloALc7FXA1gQfX2VcwFbtf4pl6umQd8kmsmEN\n0AJJ+riyMMrPcHZV0PCo9Kq4ex+6bLFENdRbjUBNk2nrQ/le8BxZLNi0QZPZTOLW\n+XXHpYd3dKJ/BJA/RfiP7sg=\n-----END PRIVATE KEY-----";
|
||||
|
||||
/** 测试专用商户 API 证书(自签名 X509,请求签名序列号取自证书) */
|
||||
private const string TEST_CERTIFICATE = "-----BEGIN CERTIFICATE-----\nMIIC4TCCAcmgAwIBAgIUIDIEwVtecMJKfY0NCx1l8Fwv7LowDQYJKoZIhvcNAQEL\nBQAwADAeFw0yNjA5MDExMzM5NThaFw0zNjA4MjkxMzM5NThaMAAwggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQChGoUMvCaiaf97XcUExEA3VP4FVwUCPQf/\nGPvm2cjHZACcPj65rjKNs5nJ0xaKELrJkUCZ3+N5YJJ4VGBBhh9GXekrzFckeqUn\n1sRS9FFbWfNeqAvz3qgps+ohnriPMkTRTw/SONTVRuNVesKz8FTYbT5Vl6jSHv71\nCMMcm6qrhvZ9av1nB03MrUN06v2OMhfXrZTBV5KI5zvIOwxetOz0MTAHcfRyrW8c\nejiJo3baRsRRHXsnDRB7p4H1KIPx+N7yTJKv/L2+tgXYmhIjKJRC5hDmrLrLOzie\nvqKlBTsBcG41UWzcLEDG96BcR6Le+dzlJIXXi2taoFXGFuDUVt/bAgMBAAGjUzBR\nMB0GA1UdDgQWBBQ2SD9beEd/0EU6ouWHHSW6Pdh8OjAfBgNVHSMEGDAWgBQ2SD9b\neEd/0EU6ouWHHSW6Pdh8OjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA\nA4IBAQByZckpIfdi5BeWwX4FZK9D5+78hZpOpDi+a8rxyd22BYYbkft5WfWnTjWh\nuXZgwOBPoYwmQ9FtC8rIeXWCadcp/ohGCkVVm9Y2CtkutrTC+uxdl4LnDPRQjXPL\nuRubAzvv3leRrphd1dOmAku32TfE0bK6Xul6pCnQaEgIlFp8zp8UkCrYcm9SPNye\nRt/1vt5iBPB6UiZ9oQL1IDUdqJV2Hl9snzHi0AzuZHy+ZMZY6WEY2lBohN8wvfak\nqrejyeA3nY7+a+QRPCg9fAOhxJlIj/eJXJXUUgJTiwTDHN16JQYcqgX5/AdrqnNj\ndkhBOR8mPPmE9hCoXwcDt017y7YV\n-----END CERTIFICATE-----";
|
||||
|
||||
/** 测试专用 APIv3 密钥(32 字节) */
|
||||
private const string TEST_SECRET_KEY = 'testSecr3tKey0123456789abcdef01';
|
||||
|
||||
/** 测试专用平台证书序列号 */
|
||||
private const string TEST_PLATFORM_SERIAL = 'TESTPUBKEYID0001';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'services.wechat.mini.appid' => 'wx-mini-test',
|
||||
'services.wechat.mini.secret' => 'wx-secret-test',
|
||||
'services.pay.online_channel' => 'wechat',
|
||||
'services.wxpay.mch_id' => '1630000001',
|
||||
'services.wxpay.private_key' => self::TEST_PRIVATE_KEY,
|
||||
'services.wxpay.certificate' => self::TEST_CERTIFICATE,
|
||||
'services.wxpay.secret_key' => self::TEST_SECRET_KEY,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 MockHttpClient 替换微信支付接口应答,并注入容器(控制器经由容器取同一实例)
|
||||
*
|
||||
* @param callable|array<int, MockResponse> $responses 应答回调或应答队列
|
||||
*/
|
||||
private function fakeWechatApi(callable|array $responses): WechatPayService
|
||||
{
|
||||
$service = app(WechatPayService::class);
|
||||
$app = $service->app();
|
||||
$app->setHttpClient(new MockHttpClient($responses, 'https://api.mch.weixin.qq.com'));
|
||||
$this->app->instance(WechatPayService::class, $service);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
/** 模拟微信 code2session 成功 */
|
||||
private function fakeCode2session(string $openid = 'oOpenidTest001'): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 造一张指定金额的未支付账单(总额=商品金额) */
|
||||
private function makeBill(StoreModel $store, string $amount, array $attributes = []): BillModel
|
||||
{
|
||||
return BillModel::create(array_merge([
|
||||
'bill_no' => 'ZD' . random_int(100000000000, 999999999999),
|
||||
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
|
||||
'store_id' => $store->id,
|
||||
'bill_date' => '2026-09-01',
|
||||
'product_amount' => $amount,
|
||||
'delivery_fee' => '0.00',
|
||||
'box_num' => 0,
|
||||
'tray_num' => 0,
|
||||
'box_price' => '0.00',
|
||||
'tray_price' => '0.00',
|
||||
'added_amount' => '0.00',
|
||||
'total_amount' => $amount,
|
||||
'status' => BillModel::STATUS_UNPAID,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 造一笔待支付的微信渠道在线支付单并锁定账单 */
|
||||
private function makeWechatPayment(StoreModel $store, string $amount, BillModel ...$bills): PaymentModel
|
||||
{
|
||||
$payment = PaymentModel::create([
|
||||
'payment_no' => 'ZF' . now()->format('Ymd') . random_int(1000, 9999),
|
||||
'store_id' => $store->id,
|
||||
'amount' => $amount,
|
||||
'pay_type' => PaymentModel::TYPE_ONLINE,
|
||||
'pay_method' => PaymentModel::METHOD_WECHAT,
|
||||
'voucher_ids' => '',
|
||||
'status' => PaymentModel::STATUS_PENDING,
|
||||
'openid' => 'oOpenidTest001',
|
||||
]);
|
||||
foreach ($bills as $bill) {
|
||||
$bill->update(['payment_id' => $payment->id]);
|
||||
}
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造微信支付结果通知报文(交易报文以 APIv3 密钥 AES-256-GCM 加密装入 resource)
|
||||
*
|
||||
* @param array<string, mixed> $resourceOverrides 交易报文覆盖项
|
||||
* @return array<string, mixed> 通知报文
|
||||
*/
|
||||
private function wechatNotifyBody(PaymentModel $payment, array $resourceOverrides = []): array
|
||||
{
|
||||
$resource = array_merge([
|
||||
'mchid' => '1630000001',
|
||||
'appid' => 'wx-mini-test',
|
||||
'out_trade_no' => $payment->payment_no,
|
||||
'transaction_id' => '4200002501202609010001',
|
||||
'trade_state' => 'SUCCESS',
|
||||
'trade_state_desc' => '支付成功',
|
||||
'success_time' => '2026-09-01T14:00:00+08:00',
|
||||
'amount' => [
|
||||
'total' => (int) bcmul((string) $payment->amount, '100'),
|
||||
'payer_total' => (int) bcmul((string) $payment->amount, '100'),
|
||||
'currency' => 'CNY',
|
||||
],
|
||||
], $resourceOverrides);
|
||||
|
||||
return [
|
||||
'id' => 'EV-202609011400000001',
|
||||
'create_time' => '2026-09-01T14:00:05+08:00',
|
||||
'resource_type' => 'encrypt-resource',
|
||||
'event_type' => 'TRANSACTION.SUCCESS',
|
||||
'summary' => '支付成功',
|
||||
'resource' => [
|
||||
'original_type' => 'transaction',
|
||||
'algorithm' => 'AEAD_AES_256_GCM',
|
||||
'ciphertext' => AesGcm::encrypt((string) json_encode($resource), self::TEST_SECRET_KEY, 'nonce1234567', 'transaction'),
|
||||
'associated_data' => 'transaction',
|
||||
'nonce' => 'nonce1234567',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** 以原始报文 + 自定义请求头发送微信通知(验签场景需要精确控制 body 参与签名) */
|
||||
private function postWechatNotify(array $body, array $headers = []): \Illuminate\Testing\TestResponse
|
||||
{
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
foreach ($headers as $name => $value) {
|
||||
$server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value;
|
||||
}
|
||||
return $this->call('POST', '/mini/payment/wechat-notify', [], [], [], $server, (string) json_encode($body));
|
||||
}
|
||||
|
||||
/** 对通知报文按微信规则签名(平台证书验签场景):timestamp\nnonce\nbody\n */
|
||||
private function signNotify(string $body, int $timestamp, string $nonce): string
|
||||
{
|
||||
openssl_sign("{$timestamp}\n{$nonce}\n{$body}\n", $signature, self::TEST_PRIVATE_KEY, 'sha256WithRSAEncryption');
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
/** 发起微信渠道在线支付:锁定账单、创建支付单、上送报文正确、返回标准 wx.requestPayment 调起参数 */
|
||||
public function test_create_wechat_payment_success(): void
|
||||
{
|
||||
$this->fakeCode2session();
|
||||
$captured = [];
|
||||
$this->fakeWechatApi(function (string $method, string $url, array $options) use (&$captured) {
|
||||
$captured = compact('method', 'url', 'options');
|
||||
return new MockResponse((string) json_encode(['prepay_id' => 'wx202609011200000001']), ['http_code' => 200]);
|
||||
});
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill1 = $this->makeBill($store, '100.00');
|
||||
$bill2 = $this->makeBill($store, '50.50');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill1->id, $bill2->id],
|
||||
'code' => 'wx-login-code',
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$paymentNo = $response->json('data.payment_no');
|
||||
$this->assertSame('150.50', $response->json('data.amount'));
|
||||
|
||||
// 调起参数为标准 wx.requestPayment 五要素,package 含 prepay_id
|
||||
$payParams = $response->json('data.pay_params');
|
||||
$this->assertSame('wx-mini-test', $payParams['appId'] ?? null);
|
||||
$this->assertNotEmpty($payParams['timeStamp']);
|
||||
$this->assertNotEmpty($payParams['nonceStr']);
|
||||
$this->assertSame('prepay_id=wx202609011200000001', $payParams['package']);
|
||||
$this->assertSame('RSA', $payParams['signType']);
|
||||
$this->assertNotEmpty($payParams['paySign']);
|
||||
|
||||
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
|
||||
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
||||
$this->assertSame(PaymentModel::METHOD_WECHAT, $payment->pay_method);
|
||||
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->status);
|
||||
$this->assertSame('', (string) $payment->order_id, '微信渠道下单成功后才回传 transaction_id');
|
||||
|
||||
// 账单锁定 + openid 绑定门店
|
||||
$this->assertSame($payment->id, $bill1->fresh()->payment_id);
|
||||
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
||||
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
||||
|
||||
// 上送微信的报文:JSAPI 下单,金额转换为分,通知地址为微信回调路由
|
||||
//(MockHttpClient 回调收到的是预处理后的 options:json 已编码进 body,header 名小写且值为数组)
|
||||
$this->assertSame('POST', $captured['method']);
|
||||
$this->assertStringContainsString('/v3/pay/transactions/jsapi', $captured['url']);
|
||||
$json = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||||
$this->assertSame('wx-mini-test', $json['appid']);
|
||||
$this->assertSame('1630000001', $json['mchid']);
|
||||
$this->assertSame($paymentNo, $json['out_trade_no']);
|
||||
$this->assertSame(15050, $json['amount']['total']);
|
||||
$this->assertSame('CNY', $json['amount']['currency']);
|
||||
$this->assertSame('oOpenidTest001', $json['payer']['openid']);
|
||||
$this->assertStringContainsString('/mini/payment/wechat-notify', $json['notify_url']);
|
||||
// 请求头携带商户签名(预处理后的 headers 为 "Name: value" 字符串列表)
|
||||
$headerLines = implode("\n", array_map('strval', (array) ($captured['options']['headers'] ?? [])));
|
||||
$this->assertStringContainsString('Authorization: WECHATPAY2-SHA256-RSA2048', $headerLines);
|
||||
}
|
||||
|
||||
/** 微信下单失败(应答 4xx):支付单作废并释放账单,可重新发起 */
|
||||
public function test_create_wechat_payment_gateway_failure_releases_bills(): void
|
||||
{
|
||||
$this->fakeCode2session();
|
||||
$this->fakeWechatApi([
|
||||
new MockResponse((string) json_encode(['code' => 'PARAM_ERROR', 'message' => 'appid 与 mchid 不匹配']), ['http_code' => 400]),
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '20.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$payment = PaymentModel::first();
|
||||
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
|
||||
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
||||
}
|
||||
|
||||
/** 微信支付成功通知:AES-GCM 解密 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
public function test_wechat_notify_settles_payment(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill1 = $this->makeBill($store, '100.00');
|
||||
$bill2 = $this->makeBill($store, '50.00');
|
||||
$payment = $this->makeWechatPayment($store, '150.00', $bill1, $bill2);
|
||||
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment))
|
||||
->assertJsonPath('code', 'SUCCESS');
|
||||
|
||||
$payment->refresh();
|
||||
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status);
|
||||
$this->assertSame('4200002501202609010001', $payment->trade_no);
|
||||
$this->assertSame('2026-09-01 14:00:00', (string) $payment->paid_at);
|
||||
|
||||
foreach ([$bill1, $bill2] as $bill) {
|
||||
$bill->refresh();
|
||||
$this->assertSame(BillModel::STATUS_PAID, $bill->status);
|
||||
$this->assertStringContainsString('微信支付', (string) $bill->pay_remark);
|
||||
$this->assertStringContainsString($payment->payment_no, (string) $bill->pay_remark);
|
||||
}
|
||||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||||
$this->assertTrue(
|
||||
NoticeModel::where('store_id', $store->id)->where('title', '账单支付成功')->exists()
|
||||
);
|
||||
|
||||
// 重复通知幂等:仍应答成功,金额不重复累加
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment))
|
||||
->assertJsonPath('code', 'SUCCESS');
|
||||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||||
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
|
||||
}
|
||||
|
||||
/** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态 / 商户号不一致:应答失败且不结账 */
|
||||
public function test_wechat_notify_rejects_invalid_messages(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '100.00');
|
||||
$payment = $this->makeWechatPayment($store, '100.00', $bill);
|
||||
|
||||
// 密文非法(密钥不匹配)→ 解密失败
|
||||
$badBody = $this->wechatNotifyBody($payment);
|
||||
$badBody['resource']['ciphertext'] = AesGcm::encrypt('{"out_trade_no":"x"}', str_repeat('x', 32), 'nonce1234567', 'transaction');
|
||||
$this->postWechatNotify($badBody)->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 金额不一致(防篡改)
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['amount' => ['total' => 9999, 'payer_total' => 9999]]))
|
||||
->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 订单号不存在
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['out_trade_no' => 'ZF000000000000']))
|
||||
->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 非支付成功状态
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['trade_state' => 'NOTPAY']))
|
||||
->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 商户号不一致(防串号)
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['mchid' => '9999999999']))
|
||||
->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 均未结账
|
||||
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->fresh()->status);
|
||||
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
|
||||
$this->assertSame('0.00', (string) $store->fresh()->total_purchase_amount);
|
||||
}
|
||||
|
||||
/** 配置平台证书后走验签链路:签名合法才受理通知 */
|
||||
public function test_wechat_notify_with_platform_cert_validates_signature(): void
|
||||
{
|
||||
config([
|
||||
'services.wxpay.platform_cert' => self::TEST_CERTIFICATE,
|
||||
'services.wxpay.platform_serial' => self::TEST_PLATFORM_SERIAL,
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '66.00');
|
||||
$payment = $this->makeWechatPayment($store, '66.00', $bill);
|
||||
|
||||
$body = (string) json_encode($this->wechatNotifyBody($payment));
|
||||
$timestamp = time();
|
||||
$nonce = 'notifyNonce001';
|
||||
|
||||
// 无签名头 → 验签失败(微信请求必带签名头)
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment))->assertJsonPath('code', 'FAIL');
|
||||
|
||||
// 签名头齐全且合法 → 受理并结账
|
||||
$this->postWechatNotify($this->wechatNotifyBody($payment), [
|
||||
'Wechatpay-Timestamp' => (string) $timestamp,
|
||||
'Wechatpay-Nonce' => $nonce,
|
||||
'Wechatpay-Serial' => self::TEST_PLATFORM_SERIAL,
|
||||
'Wechatpay-Signature' => $this->signNotify($body, $timestamp, $nonce),
|
||||
])->assertJsonPath('code', 'SUCCESS');
|
||||
|
||||
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->fresh()->status);
|
||||
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
|
||||
}
|
||||
|
||||
/** 主动查询:微信已支付则同步结账;未支付保持待支付;已结账后不再请求微信 */
|
||||
public function test_wechat_query_syncs_status(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '80.00');
|
||||
$payment = $this->makeWechatPayment($store, '80.00', $bill);
|
||||
|
||||
$queryCount = 0;
|
||||
$this->fakeWechatApi(function (string $method, string $url) use (&$queryCount, $payment) {
|
||||
$queryCount++;
|
||||
$this->assertSame('GET', $method);
|
||||
$this->assertStringContainsString('/v3/pay/transactions/out-trade-no/' . $payment->payment_no, $url);
|
||||
$data = $queryCount === 1
|
||||
? ['trade_state' => 'NOTPAY', 'out_trade_no' => $payment->payment_no]
|
||||
: [
|
||||
'trade_state' => 'SUCCESS',
|
||||
'out_trade_no' => $payment->payment_no,
|
||||
'transaction_id' => '4200002501202609010002',
|
||||
'success_time' => '2026-09-01T15:00:00+08:00',
|
||||
'amount' => ['total' => 8000, 'payer_total' => 8000],
|
||||
];
|
||||
return new MockResponse((string) json_encode($data), ['http_code' => 200]);
|
||||
});
|
||||
|
||||
// 场景一:微信未支付
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_PENDING);
|
||||
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
|
||||
|
||||
// 场景二:微信已支付 → 查询即结账
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED)
|
||||
->assertJsonPath('data.trade_no', '4200002501202609010002');
|
||||
|
||||
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
|
||||
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
|
||||
|
||||
// 已结账后重复查询不再请求微信(本地直接返回)
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED);
|
||||
$this->assertSame(2, $queryCount, '已结账后不再请求微信');
|
||||
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
|
||||
}
|
||||
|
||||
/** 渠道开关为旺铺(默认)时不受影响:仍走旺铺下单(pay_method=旺铺支付,旺铺通道报错而非微信) */
|
||||
public function test_wangpu_channel_remains_default(): void
|
||||
{
|
||||
config(['services.pay.online_channel' => 'wangpu']);
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200),
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '10.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
// 未配置旺铺密钥:旺铺通道在本地装信封即报错(证明分发到旺铺而非微信),支付单作废释放账单
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$payment = PaymentModel::first();
|
||||
$this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method);
|
||||
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
|
||||
$this->assertSame(0, $bill->fresh()->payment_id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user