Compare commits
24 Commits
d190bdaade
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3005646179 | |||
| edf5e42c38 | |||
| 58c733baa2 | |||
| 7de51391e4 | |||
| 379aeac2b3 | |||
| a636bd31b5 | |||
| b784210012 | |||
| 51ccaaf11a | |||
| 7a895c143e | |||
| f9eb6f7d32 | |||
| f8e81cb830 | |||
| 52f396c12e | |||
| b1bb40a088 | |||
| 12cbed411b | |||
| 364471a086 | |||
| 5c7a5829af | |||
| 17c1281073 | |||
| 968054b9ff | |||
| c33247f214 | |||
| 1e25459676 | |||
| cf1d3a6229 | |||
| 52e339cdd3 | |||
| c986c86ed5 | |||
| 1370926780 |
+22
-3
@@ -47,16 +47,35 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=
|
||||
MAIL_FROM_NAME=
|
||||
|
||||
# 微信小程序(code2session 换取 openid,在线支付必需)
|
||||
# 微信小程序(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_SIGN_KEY=
|
||||
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
@@ -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=全部)
|
||||
@@ -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,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 => 名称(导出列,升序)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -130,10 +150,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,
|
||||
@@ -164,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';
|
||||
|
||||
// 空行
|
||||
@@ -185,6 +205,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 +219,14 @@ 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++;
|
||||
], 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);
|
||||
@@ -208,40 +235,107 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
||||
}
|
||||
}
|
||||
|
||||
// 合计行(行合计 + 门店列合计)
|
||||
// 合计行(行合计 + 门店列合计;门店无数据显示空白)
|
||||
$rows[] = array_merge(
|
||||
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
||||
array_values($storeTotals),
|
||||
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, 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++) {
|
||||
$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);
|
||||
$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 +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 处理;仅展示参考)
|
||||
*/
|
||||
|
||||
@@ -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,29 +10,37 @@ 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;
|
||||
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header) */
|
||||
private array $specialRows = [];
|
||||
|
||||
/** 列头所在行索引 */
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 采购单
|
||||
@@ -53,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计
|
||||
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
@@ -66,11 +74,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
// 标题行
|
||||
$rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $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++;
|
||||
|
||||
@@ -79,35 +92,26 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
$this->specialRows[++$rowIndex] = 'header';
|
||||
$this->headerRow = $rowIndex;
|
||||
|
||||
// 明细行(有数据的单元格记录到 dataCells:品名/汇总/门店数量)
|
||||
$totalQuantity = 0;
|
||||
$storeTotals = array_fill_keys($storeIds, 0);
|
||||
// 明细行(0 数量的门店格留空不填色)
|
||||
foreach ($this->productRows as $productRow) {
|
||||
$line = [
|
||||
$productRow['product_name'],
|
||||
(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;
|
||||
}
|
||||
$rows[] = $line;
|
||||
$this->dataCells[++$rowIndex] = $filled;
|
||||
$totalQuantity += (int) $productRow['quantity'];
|
||||
$this->rowData[++$rowIndex] = [
|
||||
'quantity' => (int) $productRow['quantity'],
|
||||
'store_quantities' => $storeQuantities,
|
||||
];
|
||||
}
|
||||
|
||||
// 合计行
|
||||
$rows[] = array_merge(
|
||||
['合计', $totalQuantity, ''],
|
||||
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
|
||||
);
|
||||
$this->specialRows[++$rowIndex] = 'summary';
|
||||
$this->lastRow = $rowIndex;
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
@@ -118,7 +122,8 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题/列头/合计加粗,有数据的单元格填充突出颜色,冻结列头
|
||||
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行;
|
||||
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
@@ -126,23 +131,56 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
|
||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||
|
||||
$widths = [24, 10, 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);
|
||||
}
|
||||
$storeCount = count($this->stores);
|
||||
for ($i = 0; $i < $storeCount; $i++) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(12);
|
||||
$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);
|
||||
}
|
||||
|
||||
// 有数据的单元格:填充突出颜色并加粗(含品名/汇总/门店数量)
|
||||
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->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +188,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
||||
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 !== []) {
|
||||
@@ -158,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;场景二选一:小程序 / 公众号 H5)
|
||||
*
|
||||
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
|
||||
* → 小程序 wx.requestPayment 完成支付
|
||||
* → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账)
|
||||
* 链路: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')]
|
||||
@@ -28,10 +29,14 @@ class OnlinePaymentController extends BaseMiniController
|
||||
public function __construct(
|
||||
protected OnlinePaymentService $onlinePayment,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatPayService $wxpay,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
|
||||
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
|
||||
*
|
||||
* scene=mini(默认):小程序 wx.login code 换 openid,wx.requestPayment 调起;
|
||||
* scene=mp:公众号 H5 网页授权 code 换 openid,WeixinJSBridge getBrandWCPayRequest 调起。
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute('/payment/online', authorize: true)]
|
||||
@@ -41,11 +46,13 @@ class OnlinePaymentController extends BaseMiniController
|
||||
'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' => '微信登录凭证缺失,请重新进入小程序',
|
||||
'code.required' => '微信登录/授权凭证缺失,请重新进入',
|
||||
'scene.in' => '支付场景不正确',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
]);
|
||||
|
||||
@@ -56,6 +63,7 @@ class OnlinePaymentController extends BaseMiniController
|
||||
array_map('intval', $data['bill_ids']),
|
||||
(string) $data['code'],
|
||||
(string) ($data['remark'] ?? ''),
|
||||
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
@@ -97,20 +105,20 @@ class OnlinePaymentController extends BaseMiniController
|
||||
}
|
||||
|
||||
/**
|
||||
* 旺铺支付结果后台通知(公开路由,解密信封后幂等结账)
|
||||
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
|
||||
*
|
||||
* 通知报文与网关应答同为加密信封(data + signature,商户私钥可解),
|
||||
* 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
|
||||
* 通知报文为明文表单(不涉及加解密),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->decryptNotify($request->all());
|
||||
$params = $this->wangpu->verifyNotify($request->all());
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', '报文解密失败');
|
||||
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', '报文验签失败');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -123,6 +131,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)
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -33,6 +34,10 @@ 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('门店未设置客户等级,无法下单,请联系客服');
|
||||
@@ -72,6 +77,7 @@ class OrderController extends BaseMiniController
|
||||
|
||||
$totalQuantity = '0';
|
||||
$totalAmount = '0';
|
||||
$totalWeight = '0';
|
||||
$now = now();
|
||||
$rows = [];
|
||||
foreach ($items as $row) {
|
||||
@@ -85,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,
|
||||
@@ -102,7 +111,7 @@ class OrderController extends BaseMiniController
|
||||
'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' => '',
|
||||
@@ -116,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,
|
||||
@@ -316,4 +325,44 @@ class OrderController extends BaseMiniController
|
||||
|
||||
return $this->success([], '订单已取消');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截单时间校验:业务配置 services.order_time_start / order_time_end(HH: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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,20 @@ 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', 'desc')
|
||||
$paginator = $query->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->paginate($pageSize);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class ProductController extends BaseController
|
||||
$params,
|
||||
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
||||
)
|
||||
->orderBy('sort', 'desc')
|
||||
->orderBy('sort')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize);
|
||||
$data->getCollection()->makeVisible('cost_price');
|
||||
|
||||
@@ -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)
|
||||
@@ -172,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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -481,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' => '',
|
||||
@@ -609,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()
|
||||
@@ -628,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,
|
||||
@@ -791,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()], '已标记为已对账');
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平分类列表组装为树
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class StoreModel extends Authenticatable
|
||||
'address',
|
||||
'payment_cycle_days',
|
||||
'openid',
|
||||
'mp_openid',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,7 +21,8 @@ use Throwable;
|
||||
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
||||
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
||||
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
||||
* 4. 售后金额 = 按门店填写的调整金额(可正负:正数=加收,负数=售后减免)
|
||||
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
|
||||
* 可正负:正数=加收,负数=售后减免)
|
||||
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
||||
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
||||
*/
|
||||
@@ -82,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];
|
||||
@@ -96,7 +106,11 @@ readonly class BillGenerateService
|
||||
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
||||
2
|
||||
);
|
||||
$afterSale = bcadd((string) ($row['after_sale'] ?? '0'), '0', 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([
|
||||
|
||||
@@ -13,42 +13,80 @@ 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';
|
||||
|
||||
/** 支付场景:微信小程序(wx.login 换 openid,wx.requestPayment 调起) */
|
||||
public const string SCENE_MINI = 'mini';
|
||||
/** 支付场景:公众号 H5(网页授权换 openid,WeixinJSBridge 调起) */
|
||||
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() 返回的登录凭证
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
|
||||
* @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 = ''): array
|
||||
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
|
||||
{
|
||||
// 换取付款人 openid 并绑定到门店(下次可直接复用)
|
||||
$openid = $this->wechat->code2session($code);
|
||||
if ((string) $store->openid !== $openid) {
|
||||
$store->openid = $openid;
|
||||
$store->save();
|
||||
$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) {
|
||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
||||
$bills = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->whereIn('id', $billIds)
|
||||
@@ -79,7 +117,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,29 +131,45 @@ 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,
|
||||
$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;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
return [$payment, $gatewayData];
|
||||
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
|
||||
$wxjsapiData = $channel === self::CHANNEL_WANGPU
|
||||
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
|
||||
: $gatewayData;
|
||||
|
||||
return [$payment, $wxjsapiData];
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
*
|
||||
* @param array<string, mixed> $params 通知报文(已验签)
|
||||
* @return bool 本次是否执行了结账(false = 重复通知)
|
||||
@@ -145,9 +199,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 +247,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 +267,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 +327,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 . ')',
|
||||
]);
|
||||
|
||||
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
|
||||
@@ -294,17 +413,32 @@ class OnlinePaymentService
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单微信子 appid(默认取小程序 appid)
|
||||
* 下单微信子 appid(默认取小程序 appid;公众号 H5 场景取公众号 appid)
|
||||
*/
|
||||
protected function subAppid(): string
|
||||
protected function subAppid(string $scene = self::SCENE_MINI): string
|
||||
{
|
||||
$subAppid = (string) site_config('pay.wangpu_sub_appid', '');
|
||||
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 = (string) config('services.wechat.mini.appid', '');
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,9 +14,13 @@ use Illuminate\Support\Str;
|
||||
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data;
|
||||
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature;
|
||||
* - 整体以 JSON 信封 POST:{serialNo, version, timestamp, data, signature, extras, organizNo};
|
||||
* - 应答/通知反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
|
||||
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
|
||||
*
|
||||
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.php(env)
|
||||
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
|
||||
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key,
|
||||
* MD5(utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
|
||||
*
|
||||
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.php(env)
|
||||
*/
|
||||
class WangpuPayService
|
||||
{
|
||||
@@ -68,26 +72,63 @@ class WangpuPayService
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知)
|
||||
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
|
||||
*
|
||||
* @param array<string, mixed> $envelope 通知信封(含 data/signature)
|
||||
* @return array<string, mixed> 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等)
|
||||
* @throws RepositoryException 报文解密失败
|
||||
* @param array<string, mixed> $params 通知表单报文(含 sign)
|
||||
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
|
||||
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
|
||||
*/
|
||||
public function decryptNotify(array $envelope): array
|
||||
public function verifyNotify(array $params): array
|
||||
{
|
||||
Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]);
|
||||
$params = $this->decryptEnvelope($envelope);
|
||||
Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]);
|
||||
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'), '/') . '/mini/payment/notify';
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +320,7 @@ class WangpuPayService
|
||||
*/
|
||||
protected function config(string $key): string
|
||||
{
|
||||
$value = site_config('pay.wangpu_' . $key, '');
|
||||
$value = site_config('wangpu.' . $key, '');
|
||||
if ($value === null || $value === '') {
|
||||
$value = config('services.wangpu.' . $key, '');
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ class WechatMiniService
|
||||
*/
|
||||
public function code2session(string $code): string
|
||||
{
|
||||
$appid = trim((string) config('services.wechat.mini.appid'));
|
||||
$secret = trim((string) config('services.wechat.mini.secret'));
|
||||
$appid = $this->appid();
|
||||
$secret = $this->config('mini_secret');
|
||||
if ($appid === '' || $secret === '') {
|
||||
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
|
||||
}
|
||||
@@ -45,4 +45,28 @@ class WechatMiniService
|
||||
|
||||
return $openid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序 AppID(在线支付下单 appid 兜底等场景复用)
|
||||
*/
|
||||
public function appid(): string
|
||||
{
|
||||
return $this->config('mini_appid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.php(env)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 换 openid(sns/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.php(env)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.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:小程序 / 公众号 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.php(env)
|
||||
*/
|
||||
protected function config(string $key): string
|
||||
{
|
||||
$value = site_config('wxpay.' . $key, '');
|
||||
if ($value === null || $value === '') {
|
||||
$value = config('services.wxpay.' . $key, '');
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -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斤=500g,1公斤/千克/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -45,17 +45,31 @@ 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 pay.wangpu_*),为空时回退到这里的 env 配置
|
||||
* 优先读取后台「系统设置 → 旺铺支付」(site_config wangpu.*),为空时回退到这里的 env 配置
|
||||
*/
|
||||
'wangpu' => [
|
||||
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
|
||||
@@ -66,6 +80,23 @@ return [
|
||||
'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'), // 支付方式代码(主扫必填)
|
||||
'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)
|
||||
],
|
||||
];
|
||||
|
||||
@@ -43,6 +43,7 @@ return new class extends Migration
|
||||
$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('备注');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,18 +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' => 12, 'group_id' => 4, 'key' => 'wangpu_base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 13, 'group_id' => 4, 'key' => 'wangpu_organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
|
||||
['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' => 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' => '下单微信子 appid(sub_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' => '微信小程序 AppID(wx.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' => '微信小程序 AppSecret(code2session 登录凭证校验),请勿泄露,留空回退 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([
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/check/${n}`,method:`put`})}async function o(t,n,r){return e({url:`/purchase/order/${t}/check`,method:`put`,data:{product_ids:n,checked:r}})}async function s(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function c(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function l(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function d(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function f(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function p(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function m(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function h(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{h as a,c,f as d,a as f,m as i,r as l,d as m,o as n,l as o,i as p,p as r,n as s,u as t,s as u};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function o(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function s(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function c(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function l(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function d(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function f(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function p(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{s as a,r as c,i as d,l as f,p as i,a as l,d as n,n as o,f as r,o as s,c as t,u};
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>XinAdmin</title>
|
||||
<script type="module" crossorigin src="/assets/index-B3rYtk3Q.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CrAEpRIR.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
|
||||
@@ -92,7 +92,7 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BeViyrx7.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bz9U6gHM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,6 +10,9 @@ use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
use Modules\SystemTool\Services\SysSiteConfigService;
|
||||
|
||||
/**
|
||||
* 账单链路与支付:订单状态随业务链自动推进(采购单完成→配送中、生成账单→已完成);
|
||||
@@ -130,6 +133,56 @@ class BillPaymentTest extends ProcurementTestCase
|
||||
$this->assertSame('5.00', (string) $bill2->total_amount, '未传售后金额不影响总金额');
|
||||
}
|
||||
|
||||
/** 售后金额按客户等级上浮折算:输入 15、上浮 1% → 实收 15.15;billPrepare 回显等级(负数同比例放大) */
|
||||
public function test_generate_bill_after_sale_marked_up_by_level(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create(['percent' => 1]);
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$level2 = CustomerLevelModel::factory()->create(['percent' => 2]);
|
||||
$store2 = StoreModel::factory()->create(['level_id' => $level2->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||
|
||||
// 两店各下一单:店1 等级价 5.05 × 4 = 20.20;店2 等级价 5.10 × 2 = 10.20
|
||||
foreach ([[$store, 4], [$store2, 2]] as [$s, $qty]) {
|
||||
$this->actingAsMiniStore($s);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true);
|
||||
|
||||
// billPrepare 回显客户等级与上浮比例(弹窗预览用)
|
||||
$prepare = $this->getJson("/purchase/order/{$purchase->id}/bill/prepare")
|
||||
->assertJsonPath('success', true)
|
||||
->json('data.stores');
|
||||
$prepareByStore = collect($prepare)->keyBy('store_id');
|
||||
$this->assertSame($level->name, $prepareByStore[$store->id]['level_name']);
|
||||
$this->assertSame('1.00', (string) $prepareByStore[$store->id]['level_percent']);
|
||||
$this->assertSame($level2->name, $prepareByStore[$store2->id]['level_name']);
|
||||
$this->assertSame('2.00', (string) $prepareByStore[$store2->id]['level_percent']);
|
||||
|
||||
// 店1 售后 15 × 101% = 15.15;店2 售后 -10 × 102% = -10.20
|
||||
$this->postJson("/purchase/order/{$purchase->id}/bill", [
|
||||
'stores' => [
|
||||
['store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => 15],
|
||||
['store_id' => $store2->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => -10],
|
||||
],
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$bill = BillModel::where('store_id', $store->id)->first();
|
||||
$this->assertSame('15.15', (string) $bill->after_sale, '15 × 101%');
|
||||
$this->assertSame('35.35', (string) $bill->total_amount, '商品 20.20 + 售后 15.15');
|
||||
|
||||
$bill2 = BillModel::where('store_id', $store2->id)->first();
|
||||
$this->assertSame('-10.20', (string) $bill2->after_sale, '-10 × 102%(负数同比例放大)');
|
||||
$this->assertSame('0.00', (string) $bill2->total_amount, '商品 10.20 + 售后 -10.20');
|
||||
}
|
||||
|
||||
/** 生成账单:售后金额为正数时加收计入总金额 */
|
||||
public function test_generate_bill_positive_after_sale_added_to_total(): void
|
||||
{
|
||||
@@ -374,4 +427,35 @@ class BillPaymentTest extends ProcurementTestCase
|
||||
$this->assertStringContainsString('voucher/a.jpg', $data['payment']['voucher_urls'][0]);
|
||||
$this->assertStringContainsString('voucher/b.jpg', $data['payment']['voucher_urls'][1], '凭证顺序保持提交顺序');
|
||||
}
|
||||
|
||||
/** 支付配置接口:返回支付方式开关(未配置时默认全部启用,配置后按勾选项返回) */
|
||||
public function test_payment_config_returns_pay_methods(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->actingAsMiniStore($store);
|
||||
|
||||
// 未配置时默认全部启用
|
||||
$this->getJson('/mini/payment/config')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.pay_methods', ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online']);
|
||||
|
||||
// 后台勾选部分支付方式
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '支付配置', 'key' => 'pay', 'remark' => '']);
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => 'pay_methods',
|
||||
'title' => '支付方式开关',
|
||||
'describe' => '',
|
||||
'values' => '["bank","online"]',
|
||||
'type' => 'Checkbox',
|
||||
'options' => '',
|
||||
'sort' => 0,
|
||||
]);
|
||||
SysSiteConfigService::refreshSiteConfig();
|
||||
|
||||
$this->getJson('/mini/payment/config')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.pay_methods', ['bank', 'online']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
|
||||
/**
|
||||
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
|
||||
@@ -142,7 +143,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 +171,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;
|
||||
}
|
||||
@@ -188,6 +189,48 @@ class ExportTest extends ProcurementTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** 商品明细导出:序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出) */
|
||||
public function test_export_hides_detail_columns_by_default(): void
|
||||
{
|
||||
[$purchase] = $this->buildPurchaseWithSuppliers();
|
||||
|
||||
$export = new PurchaseOrderExport($purchase);
|
||||
$sheet = (new Spreadsheet())->getActiveSheet();
|
||||
$export->styles($sheet);
|
||||
|
||||
// 序号A/分类B/包规F/单位G/成本H/单价I/实际称重K/金额L 默认隐藏
|
||||
foreach (['A', 'B', 'F', 'G', 'H', 'I', 'K', 'L'] as $column) {
|
||||
$this->assertFalse($sheet->getColumnDimension($column)->getVisible(), "列 {$column} 应默认隐藏");
|
||||
}
|
||||
// 品名/供应商/市场/数量与门店列保持可见
|
||||
foreach (['C', 'D', 'E', 'J', 'M', 'N'] as $column) {
|
||||
$this->assertTrue($sheet->getColumnDimension($column)->getVisible(), "列 {$column} 应保持可见");
|
||||
}
|
||||
// 数据照常导出:列头与合计行仍含隐藏列数据
|
||||
$rows = $export->collection()->values();
|
||||
$header = $rows[3];
|
||||
$this->assertSame('序号', $header[0]);
|
||||
$this->assertSame('金额', $header[11]);
|
||||
$total = $rows->last();
|
||||
$this->assertSame('合计', $total[2]);
|
||||
$this->assertSame(45.0, (float) $total[11]);
|
||||
}
|
||||
|
||||
/** 商品明细导出:范围=全部未删除商品(含下架)∪ 本采购单有订货商品;已删除且无订货的商品不导出 */
|
||||
public function test_export_scope_includes_off_shelf_but_not_deleted(): void
|
||||
{
|
||||
[$purchase] = $this->buildPurchaseWithSuppliers();
|
||||
$offShelf = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF, 'cost_price' => '8.00']);
|
||||
$deleted = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
|
||||
$deleted->delete();
|
||||
|
||||
$rows = (new PurchaseOrderExport($purchase))->collection()->values();
|
||||
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
|
||||
|
||||
$this->assertContains($offShelf->name, $names);
|
||||
$this->assertNotContains($deleted->name, $names);
|
||||
}
|
||||
|
||||
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
|
||||
public function test_export_supplier_filter(): void
|
||||
{
|
||||
@@ -307,8 +350,9 @@ class ExportTest extends ProcurementTestCase
|
||||
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
|
||||
return false;
|
||||
}
|
||||
$totalA = $rowsA->last();
|
||||
if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) {
|
||||
// 无合计行:末行即最后一条明细
|
||||
$lastA = $rowsA->last();
|
||||
if ($lastA[0] === '合计') {
|
||||
return false;
|
||||
}
|
||||
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A
|
||||
@@ -366,10 +410,10 @@ class ExportTest extends ProcurementTestCase
|
||||
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
|
||||
return false;
|
||||
}
|
||||
// 每个工作表仅含本市场商品
|
||||
// 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
|
||||
foreach ($sheets as $sheet) {
|
||||
$rows = $sheet->collection()->values();
|
||||
$names = $rows->slice(3, -1)->map(static fn ($row) => $row[0])->values()->all();
|
||||
$names = $rows->slice(3)->map(static fn ($row) => $row[0])->values()->all();
|
||||
$expected = match (true) {
|
||||
str_ends_with($sheet->title(), '新发地') => [$vegA->name],
|
||||
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Services\WechatPayService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* 公众号 H5 JSAPI 在线支付(scene=mp):网页授权 code 换 openid → 公众号 appid 下单 → 调起参数
|
||||
*
|
||||
* - 微信公众号维度 openid 与小程序 openid 不同:分场景绑定(store.mp_openid / store.openid 互不覆盖)
|
||||
* - 微信官方渠道:JSAPI 下单 appid=公众号 appid,调起参数喂 WeixinJSBridge getBrandWCPayRequest
|
||||
* - 旺铺渠道:sub_appid=公众号 appid、payway_code 取 mp_payway_code(留空回退 payway_code)
|
||||
*/
|
||||
class MiniMpPaymentTest extends ProcurementTestCase
|
||||
{
|
||||
/** 测试专用微信商户 API 私钥(自签名,仅测试使用) */
|
||||
private const string WXPAY_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 WXPAY_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 WXPAY_SECRET_KEY = 'testSecr3tKey0123456789abcdef01';
|
||||
|
||||
/** 测试专用旺铺 RSA 密钥对(仅测试使用,公私钥同源便于加解密回环) */
|
||||
private const string WANGPU_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB';
|
||||
|
||||
private const string WANGPU_PRIVATE_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpnjPtRq06dsGiVykWFbarnzkgjtq0cr3D+ilD+TxBSqPCTAhU1jML2p/9IUOg6cwG59nBLa0zxRQ9yA2jLSFuZHlCoGyn26AFpIvVkdvmNK2CmKTNYSMWTJVHlYu3GXifEq/RHmlX+Z+rRFewilu61mq6CddG8c9hqpsqiZfN/QUqXGZum885ewf0v2tiuc5IpDW3Xq8BrpSupR2SB1AEfv6EjiSIPBjQwhlU6EOEHivQ4OulYx3RTfKtuHCkdl20XYHd22fclJL3YXXpijsd5zz5NTM0pfQy7WufTsXrIZl5R/7n96BewFzYzHVOBmzAWW+SYdL3qRsf2ConIY5XAgMBAAECggEAANg+h97uaNFYF9c5XjNeLYU0ULqkeD6SKAApIpgTY+6w6FkBoWIuT1y+q0jNYqKpwN+Ds2+vfizT7GX5Bz1iLoN8etHawBeLCmIus83A75VeAr20gOyy6pWv3Qlty1yvMsczEydqsL3vA0+gdQoB2RW+ib4uKmxl66WNR+xQE0DaGp3GGe117l4PrhqZWsMMugQ2sLJy6ZZ/L6+ILnTPYwz+6NdOor8owoSEYHpKJ03wTcDGdMtRkzanEtfBNklHolymof+aDwXeugw5teGm+/AtaTf8dINeUIGp6AFrUa+g78wyO/piUf+inLNa+e6vgGqck+ErsbRPb6VFQFBsQQKBgQDqkgfXGETdBtE5g9QiV2aY+DDHzcmQZBLgRGDoHFhn2SgRdG2E66Xq6uGEnwIpqLuKsCYe4t2JvNjSVFZ14Ul8DOow6NQfWLPxU6YT3fShAh/gTMgccKb7/Pe3fQqwl+6aEV3oMOKNFErKSGrkSptLh9Nf/ii9khpMFhUARErgjQKBgQC5HRwRb45CIkEeS2Q1Gz2xS4ezXACw9P0h4Qp5bMQmqgn/fjN+t4HJRlHCuDfLTnHZ48Vpihwu7BU1RmK1ozXJswDzQXCYE4ejEXTGVoUNxCHht4/VlAbBVbcnOsIREgvtZRIOHvPaMqUQqeSX9e3HcEAHfIaJRkVAhalQnKMrcwKBgDUtH7viU5Irvnikaw3R9H9PHHffLgeeuCzBM5rK+juong2+8CkG5tknoDJZfbsF9mYNYsbztTdJaXndBrC4ftkxcFHgJl5o1Hor9WVhlth9S86keWUBIMnVYi7lmOvJtZyVvU0q7+D9rarH2fug8i2gQAnt6zx2h6GiC+bAlJztAoGBAI/YxgnqhUJw+ec/sKPwAjW2usGu2b6o8deU153Z3mcpNVG70OpEUW+F3F0S6BBtad1muO41a4cu36AhjO0W4eJV3oQpMwSKEJmwI+1IKGa1JZsQGI5gVAuCvyuV5l57hpc4NhqRBO9m8YwMaV2ItviHCsqGgslDuObVtue0gLtvAoGAWSPjf+POrVy2VJ0yY9zJ6Ks1oGLYzejUTx6Ue/DmjZcOcn6IBGqvgj10D5Gu9vX847GiC8D632A3US4FMLJZki0IxEYWME3d3pg0vhqS24YxZwIo97JvxnrHOqMvd486K+Cyvlft8xgxlZdL79Ez5eec4Lvw7OoQaaoxcdDvf08=';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'services.wechat.mp.appid' => 'wx-mp-test',
|
||||
'services.wechat.mp.secret' => 'wx-mp-secret',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 模拟公众号网页授权 code 换 openid 成功 */
|
||||
private function fakeMpOauth(string $openid = 'oMpOpenid001'): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response([
|
||||
'access_token' => 'mp-access-token',
|
||||
'expires_in' => 7200,
|
||||
'refresh_token' => 'mp-refresh-token',
|
||||
'openid' => $openid,
|
||||
'scope' => 'snsapi_base',
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 以 MockHttpClient 替换微信支付接口应答,并注入容器(控制器经由容器取同一实例) */
|
||||
private function fakeWechatApi(callable $callback): void
|
||||
{
|
||||
$service = app(WechatPayService::class);
|
||||
$service->app()->setHttpClient(new MockHttpClient($callback, 'https://api.mch.weixin.qq.com'));
|
||||
$this->app->instance(WechatPayService::class, $service);
|
||||
}
|
||||
|
||||
/** 密钥 PEM 包装(与服务内实现一致) */
|
||||
private function pem(string $body, string $kind): string
|
||||
{
|
||||
return "-----BEGIN {$kind} KEY-----\n" . wordwrap($body, 64, "\n", true) . "\n-----END {$kind} KEY-----";
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟网关加密信封(demo 协议:AES-128-ECB 加密报文 + RSA 公钥加密 AES 密钥)
|
||||
*
|
||||
* @param array<string, mixed> $data 业务报文
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function gatewayEnvelope(array $data): array
|
||||
{
|
||||
$key = Str::random(16);
|
||||
openssl_public_encrypt($key, $encryptedKey, $this->pem(self::WANGPU_PUBLIC_KEY, 'PUBLIC'));
|
||||
return [
|
||||
'serialNo' => Str::random(32),
|
||||
'version' => '1.0',
|
||||
'timestamp' => now()->format('YmdHis'),
|
||||
'data' => base64_encode((string) openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA)),
|
||||
'signature' => base64_encode($encryptedKey),
|
||||
'extras' => '',
|
||||
'organizNo' => 'org001',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密我方发往网关的请求信封(断言上送报文用;服务侧 urlencode 需先解码)
|
||||
*
|
||||
* @param array<string, mixed> $body 请求信封
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decryptRequest(array $body): array
|
||||
{
|
||||
openssl_private_decrypt(
|
||||
(string) base64_decode(urldecode((string) $body['signature'])),
|
||||
$key,
|
||||
$this->pem(self::WANGPU_PRIVATE_KEY, 'PRIVATE')
|
||||
);
|
||||
$plain = openssl_decrypt(
|
||||
(string) base64_decode(urldecode((string) $body['data'])),
|
||||
'AES-128-ECB',
|
||||
(string) $key,
|
||||
OPENSSL_RAW_DATA
|
||||
);
|
||||
return (array) json_decode((string) $plain, true);
|
||||
}
|
||||
|
||||
/** 旺铺渠道测试配置 */
|
||||
private function configWangpu(): void
|
||||
{
|
||||
config([
|
||||
'services.wangpu.base_url' => 'https://wangpu.test',
|
||||
'services.wangpu.organiz_no' => 'org001',
|
||||
'services.wangpu.mer_no' => 'mer001',
|
||||
'services.wangpu.mer_code' => 'code001',
|
||||
'services.wangpu.term_code' => 'term001',
|
||||
'services.wangpu.public_key' => self::WANGPU_PUBLIC_KEY,
|
||||
'services.wangpu.private_key' => self::WANGPU_PRIVATE_KEY,
|
||||
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||
'services.wangpu.mp_payway_code' => 'WECHAT_JSPAY',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 造一张指定金额的未支付账单(总额=商品金额) */
|
||||
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-04',
|
||||
'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));
|
||||
}
|
||||
|
||||
/** 微信官方渠道公众号下单:JSAPI appid=公众号、付款人=公众号 openid、调起参数同构、mp_openid 绑定且不覆盖小程序 openid */
|
||||
public function test_mp_scene_wechat_channel(): void
|
||||
{
|
||||
config([
|
||||
'services.pay.online_channel' => 'wechat',
|
||||
'services.wxpay.mch_id' => '1630000001',
|
||||
'services.wxpay.private_key' => self::WXPAY_PRIVATE_KEY,
|
||||
'services.wxpay.certificate' => self::WXPAY_CERTIFICATE,
|
||||
'services.wxpay.secret_key' => self::WXPAY_SECRET_KEY,
|
||||
]);
|
||||
|
||||
$this->fakeMpOauth();
|
||||
$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' => 'wx-mp-prepay-001']), ['http_code' => 200]);
|
||||
});
|
||||
|
||||
$store = StoreModel::factory()->create(['openid' => 'oMiniOpenidKeep']);
|
||||
$bill = $this->makeBill($store, '66.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'code' => 'mp-oauth-code',
|
||||
'scene' => 'mp',
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
// 调起参数:appId=公众号 appid,package 含 prepay_id(H5 喂 WeixinJSBridge getBrandWCPayRequest)
|
||||
$payParams = $response->json('data.pay_params');
|
||||
$this->assertSame('wx-mp-test', $payParams['appId']);
|
||||
$this->assertSame('prepay_id=wx-mp-prepay-001', $payParams['package']);
|
||||
$this->assertNotEmpty($payParams['timeStamp']);
|
||||
$this->assertNotEmpty($payParams['nonceStr']);
|
||||
$this->assertSame('RSA', $payParams['signType']);
|
||||
$this->assertNotEmpty($payParams['paySign']);
|
||||
|
||||
// 上送微信报文:appid=公众号 appid、payer=公众号 openid、金额元转分
|
||||
$body = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||||
$this->assertSame('wx-mp-test', $body['appid'] ?? null);
|
||||
$this->assertSame('1630000001', $body['mchid'] ?? null);
|
||||
$this->assertSame('oMpOpenid001', $body['payer']['openid'] ?? null);
|
||||
$this->assertSame(6600, $body['amount']['total'] ?? null);
|
||||
|
||||
// 支付单与门店绑定:公众号 openid 写 mp_openid,不覆盖小程序 openid
|
||||
$payment = PaymentModel::where('payment_no', $response->json('data.payment_no'))->first();
|
||||
$this->assertSame('oMpOpenid001', $payment->openid);
|
||||
$store->refresh();
|
||||
$this->assertSame('oMpOpenid001', $store->mp_openid);
|
||||
$this->assertSame('oMiniOpenidKeep', $store->openid, '公众号场景不得覆盖小程序 openid');
|
||||
}
|
||||
|
||||
/** 旺铺渠道公众号下单:sub_appid=公众号 appid、payway_code=公众号支付方式代码、open_id=公众号 openid */
|
||||
public function test_mp_scene_wangpu_channel(): void
|
||||
{
|
||||
$this->configWangpu();
|
||||
$this->fakeMpOauth();
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oMpOpenid001', 'access_token' => 'at'], 200),
|
||||
'https://wangpu.test/industrial/payment/order' => Http::response(
|
||||
['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([
|
||||
'order_id' => 'WP202609040001',
|
||||
'tradeNo' => 'T20260904001',
|
||||
'user_openid' => 'oMpOpenid001',
|
||||
]),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '88.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'code' => 'mp-oauth-code',
|
||||
'scene' => 'mp',
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$paymentNo = $response->json('data.payment_no');
|
||||
$this->assertSame('oMpOpenid001', $store->fresh()->mp_openid);
|
||||
|
||||
// 上送网关的加密信封:解出业务报文校验公众号 sub_appid / 公众号 payway_code / 公众号 openid
|
||||
Http::assertSent(function ($request) use ($paymentNo) {
|
||||
if (! str_contains($request->url(), '/industrial/payment/order')) {
|
||||
return false;
|
||||
}
|
||||
$plain = $this->decryptRequest($request->data());
|
||||
return ($plain['mer_order_id'] ?? '') === $paymentNo
|
||||
&& ($plain['order_amt'] ?? '') === '88.00'
|
||||
&& ($plain['open_id'] ?? '') === 'oMpOpenid001'
|
||||
&& ($plain['sub_appid'] ?? '') === 'wx-mp-test'
|
||||
&& ($plain['payway_code'] ?? '') === 'WECHAT_JSPAY'
|
||||
&& ! empty($plain['notifyurl']);
|
||||
});
|
||||
}
|
||||
|
||||
/** 公众号网页授权失败:errcode 应答 → 下单拒绝并提示授权失败 */
|
||||
public function test_mp_scene_oauth_failure(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200),
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '10.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'code' => 'bad-code',
|
||||
'scene' => 'mp',
|
||||
])->assertJsonPath('success', false);
|
||||
|
||||
$this->assertStringContainsString('微信网页授权失败', (string) $response->json('msg'));
|
||||
// 授权失败不产生支付单、账单不被锁定
|
||||
$this->assertSame(0, PaymentModel::count());
|
||||
$this->assertSame(0, $bill->fresh()->payment_id);
|
||||
}
|
||||
|
||||
/** 公众号未配置 AppID/Secret:明确报错 */
|
||||
public function test_mp_scene_requires_mp_config(): void
|
||||
{
|
||||
config([
|
||||
'services.wechat.mp.appid' => '',
|
||||
'services.wechat.mp.secret' => '',
|
||||
]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '10.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'code' => 'mp-oauth-code',
|
||||
'scene' => 'mp',
|
||||
])->assertJsonPath('success', false);
|
||||
|
||||
$this->assertStringContainsString('微信公众号未配置', (string) $response->json('msg'));
|
||||
$this->assertSame(0, PaymentModel::count());
|
||||
}
|
||||
|
||||
/** 不传 scene 默认走小程序链路(code2session,小程序 appid 下单) */
|
||||
public function test_default_scene_remains_mini(): void
|
||||
{
|
||||
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::WXPAY_PRIVATE_KEY,
|
||||
'services.wxpay.certificate' => self::WXPAY_CERTIFICATE,
|
||||
'services.wxpay.secret_key' => self::WXPAY_SECRET_KEY,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oMiniOpenid001', 'session_key' => 'sk'], 200),
|
||||
]);
|
||||
$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' => 'wx-mini-prepay-001']), ['http_code' => 200]);
|
||||
});
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '20.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'code' => 'wx-login-code',
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
// 小程序链路:appId=小程序 appid、openid 绑定到 store.openid,mp_openid 保持空
|
||||
$this->assertSame('wx-mini-test', $response->json('data.pay_params.appId'));
|
||||
$body = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||||
$this->assertSame('wx-mini-test', $body['appid'] ?? null);
|
||||
$store->refresh();
|
||||
$this->assertSame('oMiniOpenid001', $store->openid);
|
||||
$this->assertSame('', (string) $store->mp_openid);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ use ReflectionMethod;
|
||||
* 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账
|
||||
*
|
||||
* 通讯协议与官方示例 demo/IndexController.php 一致:
|
||||
* 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输。
|
||||
* 下单/查询的业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输;
|
||||
* 支付结果后台通知为明文表单报文,仅 MD5 验签(ASCII 升序拼接 + 专用加签Key)。
|
||||
*
|
||||
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
|
||||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||||
@@ -44,6 +45,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
|
||||
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
|
||||
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||
'services.wangpu.sign_key' => 'notify-test-key',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -106,6 +108,14 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'order_id' => 'WP202608270001',
|
||||
'tradeNo' => 'T20260827001',
|
||||
'user_openid' => $openid,
|
||||
'wxjsapistr' => (string) json_encode([
|
||||
'appId' => 'wx-mini-test',
|
||||
'timeStamp' => '1724745600',
|
||||
'nonceStr' => 'nonce001',
|
||||
'package' => 'prepay_id=wp-prepay-001',
|
||||
'signType' => 'RSA',
|
||||
'paySign' => 'sign001',
|
||||
]),
|
||||
]),
|
||||
200
|
||||
),
|
||||
@@ -151,10 +161,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/** 构造加密的支付成功通知信封 */
|
||||
private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||
/** 构造带 MD5 签名的支付成功通知表单报文(加签算法与官方文档一致,测试侧独立实现) */
|
||||
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||
{
|
||||
return $this->gatewayEnvelope(array_merge([
|
||||
$params = array_merge([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_status' => '1',
|
||||
'order_amt' => (string) $payment->amount,
|
||||
@@ -166,7 +176,13 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'mer_no' => 'mer001',
|
||||
'device_no' => 'dev001',
|
||||
'order_title' => '账单合并付款',
|
||||
], $overrides));
|
||||
], $overrides);
|
||||
|
||||
$signParams = array_filter($params, static fn ($value): bool => $value !== null && $value !== '');
|
||||
ksort($signParams, SORT_STRING);
|
||||
$str = implode('&', array_map(static fn ($k, $v) => $k . '=' . $v, array_keys($signParams), $signParams));
|
||||
$params['sign'] = strtoupper(md5($str . '&key=' . config('services.wangpu.sign_key')));
|
||||
return $params;
|
||||
}
|
||||
|
||||
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
|
||||
@@ -186,19 +202,41 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */
|
||||
public function test_notify_envelope_round_trip(): void
|
||||
/** 通知 MD5 验签 golden test:官方文档示例报文 + SignKey 直算签名一致(含中文、空值剔除) */
|
||||
public function test_notify_sign_matches_doc_golden(): void
|
||||
{
|
||||
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
|
||||
|
||||
$params = [
|
||||
'mer_order_id' => 'ZF202608270001',
|
||||
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
|
||||
'trade_no' => '11420200410120144102483',
|
||||
'mer_no' => '2001071119360E5Riu',
|
||||
'order_amt' => '0.01',
|
||||
'payway_code' => 'QR_WECHAT_BARPAY',
|
||||
'order_id' => '202004101201444525348059',
|
||||
'order_status' => '1',
|
||||
'order_amt' => '150.50',
|
||||
'order_title' => '账单合并付款', // 中文报文
|
||||
'order_title' => '住宿酒店',
|
||||
'mer_code' => 'W00000000001381',
|
||||
'device_no' => 'CBC92E5GTL000083',
|
||||
'order_time' => '2020-04-10 12:01:44',
|
||||
'trade_time' => '2020-04-10 12:01:47',
|
||||
'gateway_mer_order_id' => '2020041012014445269',
|
||||
'fee' => '', // 空值不参与签名(平台不下发空值数据元)
|
||||
'sign' => 'A31998F2E0549E0A80B2A4B3A0473784', // 文档示例签名值
|
||||
];
|
||||
|
||||
$decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params));
|
||||
$verified = app(WangpuPayService::class)->verifyNotify($params);
|
||||
|
||||
$this->assertSame($params, $decrypted);
|
||||
$this->assertSame($params, $verified);
|
||||
}
|
||||
|
||||
/** 通知验签失败(签名缺失/错误)抛异常 */
|
||||
public function test_notify_verify_rejects_bad_sign(): void
|
||||
{
|
||||
$service = app(WangpuPayService::class);
|
||||
|
||||
$this->expectException(RepositoryException::class);
|
||||
$service->verifyNotify(['mer_order_id' => 'ZF202608270001', 'sign' => 'INVALIDSIGN']);
|
||||
}
|
||||
|
||||
/** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */
|
||||
@@ -228,7 +266,9 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
|
||||
$paymentNo = $response->json('data.payment_no');
|
||||
$this->assertSame('150.50', $response->json('data.amount'));
|
||||
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
|
||||
// 调起支付参数取自网关应答 wxjsapistr(小程序 wx.requestPayment 直接透传)
|
||||
$this->assertSame('wx-mini-test', $response->json('data.pay_params.appId'));
|
||||
$this->assertSame('prepay_id=wp-prepay-001', $response->json('data.pay_params.package'));
|
||||
|
||||
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
|
||||
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
||||
@@ -336,7 +376,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
||||
}
|
||||
|
||||
/** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
/** 支付成功通知:验签 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
public function test_notify_settles_payment(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
@@ -344,7 +384,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
$bill2 = $this->makeBill($store, '50.00');
|
||||
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
|
||||
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
->assertJsonPath('code', '00');
|
||||
|
||||
$payment->refresh();
|
||||
@@ -366,34 +406,39 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
);
|
||||
|
||||
// 重复通知幂等:仍应答成功,金额不重复累加
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
->assertJsonPath('code', '00');
|
||||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||||
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
|
||||
}
|
||||
|
||||
/** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
|
||||
/** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
|
||||
public function test_notify_rejects_invalid_messages(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '100.00');
|
||||
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
|
||||
|
||||
// 信封 signature 非法 → 解密失败
|
||||
$badEnvelope = $this->encryptedNotifyParams($payment);
|
||||
$badEnvelope['signature'] = 'INVALIDSIGN';
|
||||
$this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01');
|
||||
// 签名非法 → 验签失败
|
||||
$badSign = $this->signedNotifyParams($payment);
|
||||
$badSign['sign'] = 'INVALIDSIGN';
|
||||
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
|
||||
|
||||
// 金额不一致(防篡改)
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||
// 篡改金额后未重签 → 验签失败
|
||||
$tampered = $this->signedNotifyParams($payment);
|
||||
$tampered['order_amt'] = '99.99';
|
||||
$this->postJson('/mini/payment/notify', $tampered)->assertJsonPath('code', '01');
|
||||
|
||||
// 金额不一致(防篡改,签名正确但金额与支付单不符)
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 订单号不存在
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 非支付成功状态
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0']))
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 均未结账
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\StoreModel;
|
||||
|
||||
@@ -105,6 +106,27 @@ class MiniProductTest extends ProcurementTestCase
|
||||
$this->assertNull($row['price']);
|
||||
}
|
||||
|
||||
/** 商品列表:先按分类树展示顺序(深度优先),同分类内按商品 sort 升序、id 升序,未入树分类排最后 */
|
||||
public function test_product_list_ordered_by_category_tree(): void
|
||||
{
|
||||
$rootA = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$rootB = ProductCategoryModel::create(['name' => '水果', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
|
||||
// 子分类 sort 与创建顺序相反,验证按 sort 而非 id 排序
|
||||
$leafA1 = ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $rootA->id, 'sort' => 1, 'status' => 1]);
|
||||
$leafA2 = ProductCategoryModel::create(['name' => '根茎', 'parent_id' => $rootA->id, 'sort' => 0, 'status' => 1]);
|
||||
|
||||
$pB = ProductModel::factory()->create(['category_id' => $rootB->id, 'status' => 1]);
|
||||
$pA1 = ProductModel::factory()->create(['category_id' => $leafA1->id, 'status' => 1]);
|
||||
$pA2Low = ProductModel::factory()->create(['category_id' => $leafA2->id, 'status' => 1, 'sort' => 1]);
|
||||
$pA2High = ProductModel::factory()->create(['category_id' => $leafA2->id, 'status' => 1, 'sort' => 9]);
|
||||
$pNone = ProductModel::factory()->create(['category_id' => 0, 'status' => 1]);
|
||||
|
||||
$ids = array_column($this->getJson('/mini/product/list')->assertOk()->json('data.data'), 'id');
|
||||
|
||||
// 树序:rootA → leafA2 → leafA1 → rootB;leafA2 内 sort 升序;无分类排最后
|
||||
$this->assertSame([$pA2Low->id, $pA2High->id, $pA1->id, $pB->id, $pNone->id], $ids);
|
||||
}
|
||||
|
||||
/** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */
|
||||
public function test_product_list_appends_cart_quantity_and_summary(): void
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseItemCheckModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
@@ -282,7 +283,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$this->assertSame('50.00', (string) $order->total_amount);
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('4.500', (string) $purchase->total_weight);
|
||||
$this->assertSame('7.500', (string) $purchase->total_weight, '4.500 手动称重 + B 店参考重量 3.000');
|
||||
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
|
||||
}
|
||||
|
||||
@@ -392,7 +393,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4');
|
||||
$this->assertSame('82.00', (string) $purchase->estimate_amount, '50+32(4 包 × 成本 8.00)');
|
||||
$this->assertSame('1.500', (string) $purchase->total_weight);
|
||||
$this->assertSame('6.500', (string) $purchase->total_weight, '原有参考重量 2+3 斤 + 新增 1.500');
|
||||
|
||||
// 重复添加同一商品 → 拒绝
|
||||
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
|
||||
@@ -402,6 +403,28 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量');
|
||||
}
|
||||
|
||||
/** 门店购买详情-新增单品未传称重:按订货量 × 规格预填参考重量并级联汇总 */
|
||||
public function test_store_item_add_defaults_reference_weight(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$extra = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 8,
|
||||
'spec' => '25斤/袋',
|
||||
'unit' => '袋',
|
||||
]);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
|
||||
'product_id' => $extra->id,
|
||||
'quantity' => 2,
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item = StoreOrderItemModel::where('product_id', $extra->id)->first();
|
||||
$this->assertSame('50.000', (string) $item->weight, '2 袋 × 25斤/袋');
|
||||
$this->assertSame('55.000', (string) $purchase->fresh()->total_weight, '原有参考重量 5.000 + 新增 50.000');
|
||||
}
|
||||
|
||||
/** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */
|
||||
public function test_store_item_update_merges_multi_order_rows(): void
|
||||
{
|
||||
@@ -585,4 +608,161 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
|
||||
$this->assertSame('10.00', (string) $item->fresh()->cost_price);
|
||||
}
|
||||
|
||||
/** 单品「已对账」标记:切换入库持久化,详情接口回显,再次切换取消 */
|
||||
public function test_item_check_toggle_persists_and_echoes_in_detail(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 初始无标记
|
||||
$this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.checked_product_ids', []);
|
||||
|
||||
// 标记 → 入库
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.checked', true);
|
||||
$this->assertDatabaseHas('purchase_item_check', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
// 详情回显
|
||||
$this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertJsonPath('data.checked_product_ids.0', $product->id);
|
||||
|
||||
// 再次切换 → 取消标记,记录删除
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.checked', false);
|
||||
$this->assertDatabaseMissing('purchase_item_check', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 对账标记与采购单状态无关:已完成采购单仍可切换标记 */
|
||||
public function test_item_check_allowed_when_purchase_completed(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.checked', true);
|
||||
}
|
||||
|
||||
/** 对账标记校验:采购单不存在 / 采购单内无此商品均报错,不入库 */
|
||||
public function test_item_check_toggle_validates_purchase_and_product(): void
|
||||
{
|
||||
[$purchase] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson('/purchase/order/99999/check/1')
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '采购单不存在');
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check/99999")
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '该采购单下无此商品的订货明细');
|
||||
|
||||
$this->assertSame(0, PurchaseItemCheckModel::count());
|
||||
}
|
||||
|
||||
/** 批量标记(全选):一次标记多个商品,重复标记幂等,批量取消仅清指定商品 */
|
||||
public function test_item_check_batch_marks_and_clears(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 采购单内再加一个商品(通过新增单品端点挂靠门店订单)
|
||||
$product2 = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 5,
|
||||
'spec' => '1斤',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
|
||||
'product_id' => $product2->id,
|
||||
'quantity' => 1,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
// 批量标记两个商品
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check", [
|
||||
'product_ids' => [$product->id, $product2->id],
|
||||
'checked' => true,
|
||||
])->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.count', 2);
|
||||
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
|
||||
|
||||
// 重复批量标记幂等(不产生重复行)
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check", [
|
||||
'product_ids' => [$product->id, $product2->id],
|
||||
'checked' => true,
|
||||
])->assertJsonPath('success', true);
|
||||
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
|
||||
|
||||
// 详情回显两个标记
|
||||
$ids = $this->getJson("/purchase/order/{$purchase->id}")->json('data.checked_product_ids');
|
||||
$this->assertEqualsCanonicalizing([$product->id, $product2->id], $ids);
|
||||
|
||||
// 批量取消其中一个
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check", [
|
||||
'product_ids' => [$product->id],
|
||||
'checked' => false,
|
||||
])->assertJsonPath('success', true);
|
||||
$this->assertDatabaseMissing('purchase_item_check', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('purchase_item_check', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $product2->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 批量标记校验:空列表/采购单不存在报错;无效商品静默忽略 */
|
||||
public function test_item_check_batch_validates_and_ignores_unknown_products(): void
|
||||
{
|
||||
[$purchase] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [], 'checked' => true])
|
||||
->assertJsonPath('success', false);
|
||||
$this->putJson('/purchase/order/99999/check', ['product_ids' => [1], 'checked' => true])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '采购单不存在');
|
||||
|
||||
// 采购单内无此商品 → 静默忽略不入库
|
||||
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [99999], 'checked' => true])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.count', 0);
|
||||
$this->assertSame(0, PurchaseItemCheckModel::count());
|
||||
}
|
||||
|
||||
/** 采购单列表「应付商品金额」列 = Σ 已生成门店账单的商品金额(未生成账单为 null) */
|
||||
public function test_list_includes_bill_product_amount(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 未生成账单 → null
|
||||
$this->getJson('/purchase/order')
|
||||
->assertJsonPath('data.data.0.bill_product_amount', null);
|
||||
|
||||
// 完成采购单并生成两店账单(各店商品金额 = 数量 × 等级价 10.00)
|
||||
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||
$this->postJson("/purchase/order/{$purchase->id}/bill", [
|
||||
'stores' => [
|
||||
['store_id' => $stores[0]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
|
||||
['store_id' => $stores[1]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
|
||||
],
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
// 列表回显:2×10.00 + 3×10.00 = 50.00
|
||||
$this->getJson('/purchase/order')
|
||||
->assertJsonPath('data.data.0.bill_product_amount', '50.00');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,4 +139,30 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||
}
|
||||
|
||||
/** 采购单总重量 = 订货明细参考重量合计(下单时已按订货量 × 规格预填) */
|
||||
public function test_generate_sums_reference_weight(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '6.00',
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '箱',
|
||||
]);
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
foreach ([3, 4] as $qty) {
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('70.000', (string) PurchaseOrderModel::first()->total_weight, '(3+4) 箱 × 10斤/箱');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
use Modules\SystemTool\Services\SysSiteConfigService;
|
||||
|
||||
/**
|
||||
* 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
@@ -72,6 +75,40 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额');
|
||||
}
|
||||
|
||||
/** 下单预填参考重量 = 订货量 × 规格折算(按重量计价的商品订货量即重量),订单总重量 = 明细合计 */
|
||||
public function test_place_order_prefills_reference_weight(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$byBox = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '5.00',
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '箱',
|
||||
]);
|
||||
$byJin = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '5.00',
|
||||
'spec' => '散装',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->postJson('/mini/order', [
|
||||
'items' => [
|
||||
['product_id' => $byBox->id, 'quantity' => 3],
|
||||
['product_id' => $byJin->id, 'quantity' => 5],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
$boxItem = $order->items->firstWhere('product_id', $byBox->id);
|
||||
$jinItem = $order->items->firstWhere('product_id', $byJin->id);
|
||||
$this->assertSame('30.000', (string) $boxItem->weight, '3 箱 × 10斤/箱');
|
||||
$this->assertSame('5.000', (string) $jinItem->weight, '按斤计价:订货量即重量');
|
||||
$this->assertSame('35.000', (string) $order->total_weight, '订单总重量 = 明细参考重量合计');
|
||||
}
|
||||
|
||||
/** 门店绑定的客户等级被删除时拒绝下单 */
|
||||
public function test_store_level_missing_rejected(): void
|
||||
{
|
||||
@@ -392,6 +429,96 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $pending1->refresh()->status);
|
||||
}
|
||||
|
||||
/** 写入下单时段业务配置并刷新缓存(value 传 null 表示该项不配置) */
|
||||
private function setOrderTimeWindow(?string $start, ?string $end): void
|
||||
{
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services', 'remark' => '']);
|
||||
foreach (['order_time_start' => $start, 'order_time_end' => $end] as $key => $value) {
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => $key,
|
||||
'title' => $key,
|
||||
'describe' => '',
|
||||
'values' => $value,
|
||||
'type' => 'Input',
|
||||
'options' => '',
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
SysSiteConfigService::refreshSiteConfig();
|
||||
}
|
||||
|
||||
/** 截单时间:下单时段外禁止下单,时段内正常下单 */
|
||||
public function test_place_order_blocked_outside_order_time_window(): void
|
||||
{
|
||||
[$store, $product] = $this->makeStoreWithProduct('5.00');
|
||||
$this->setOrderTimeWindow('08:00', '18:00');
|
||||
$this->actingAsMiniStore($store);
|
||||
|
||||
// 20:00 已截单 → 拒绝且不生成订单
|
||||
$this->travelTo('2026-09-05 20:00:00');
|
||||
$response = $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
$response->assertOk()->assertJsonPath('success', false);
|
||||
$this->assertStringContainsString('不在下单时段内', (string) $response->json('msg'));
|
||||
$this->assertStringContainsString('08:00 - 18:00', (string) $response->json('msg'));
|
||||
$this->assertSame(0, StoreOrderModel::count());
|
||||
|
||||
// 边界:08:00 开始时间与 18:00 截单时间均可下单
|
||||
$this->travelTo('2026-09-05 08:00:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$this->travelTo('2026-09-05 18:00:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$this->assertSame(2, StoreOrderModel::count());
|
||||
}
|
||||
|
||||
/** 截单时间:跨天时段(开始时间晚于截单时间,如 20:00-次日06:00) */
|
||||
public function test_place_order_cross_midnight_time_window(): void
|
||||
{
|
||||
[$store, $product] = $this->makeStoreWithProduct('5.00');
|
||||
$this->setOrderTimeWindow('20:00', '06:00');
|
||||
$this->actingAsMiniStore($store);
|
||||
|
||||
// 当晚 22:00 与 次日 05:00 均在时段内
|
||||
$this->travelTo('2026-09-05 22:00:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$this->travelTo('2026-09-06 05:00:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 中午 12:00 不在时段内 → 拒绝
|
||||
$this->travelTo('2026-09-06 12:00:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(2, StoreOrderModel::count());
|
||||
}
|
||||
|
||||
/** 截单时间:只配置截单时间时按单边限制(截止后不能下单);格式非法按未配置处理 */
|
||||
public function test_place_order_only_cutoff_time_and_invalid_config(): void
|
||||
{
|
||||
[$store, $product] = $this->makeStoreWithProduct('5.00');
|
||||
$this->setOrderTimeWindow(null, '18:00');
|
||||
$this->actingAsMiniStore($store);
|
||||
|
||||
$this->travelTo('2026-09-05 17:59:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$this->travelTo('2026-09-05 18:01:00');
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
// 非法格式不拦截(避免误配置导致全天无法下单)
|
||||
SysSiteConfigItemsModel::query()->update(['values' => 'abc']);
|
||||
SysSiteConfigService::refreshSiteConfig();
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
/** 订单列表商品预览:附带首图/规格/单位供小程序端展示 */
|
||||
public function test_order_list_preview_includes_image_spec_and_unit(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\WeightEstimator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 参考重量估算:重量单位直算、规格解析折算、无法解析兜底(单位:斤)
|
||||
*/
|
||||
class WeightEstimatorTest extends TestCase
|
||||
{
|
||||
/** 计价单位本身是重量单位:订货量即重量(忽略规格) */
|
||||
public function test_weight_unit_quantity_is_weight(): void
|
||||
{
|
||||
$this->assertSame('5.000', WeightEstimator::estimate('10斤/箱', '斤', '5'));
|
||||
$this->assertSame('6.000', WeightEstimator::estimate('', '公斤', '3'));
|
||||
$this->assertSame('6.000', WeightEstimator::estimate('', 'KG', '3'));
|
||||
$this->assertSame('1.000', WeightEstimator::estimate('', '克', '500'));
|
||||
}
|
||||
|
||||
/** 规格解析每件重量 × 订货量 */
|
||||
public function test_spec_per_unit_weight(): void
|
||||
{
|
||||
$this->assertSame('30.000', WeightEstimator::estimate('10斤/箱', '箱', '3'));
|
||||
$this->assertSame('75.000', WeightEstimator::estimate('25斤/袋', '袋', '3'));
|
||||
$this->assertSame('3.000', WeightEstimator::estimate('500g/袋', '袋', '3'));
|
||||
$this->assertSame('3.000', WeightEstimator::estimate('500克/袋', '袋', '3'));
|
||||
$this->assertSame('9.000', WeightEstimator::estimate('1.5kg/箱', '箱', '3'));
|
||||
$this->assertSame('12.000', WeightEstimator::estimate('2公斤/筐', '筐', '3'));
|
||||
$this->assertSame('15.000', WeightEstimator::estimate('10斤/箱', '箱', '1.5'), '订货量可为小数');
|
||||
}
|
||||
|
||||
/** 规格无法解析:裸数字按斤计,无数字计 0 */
|
||||
public function test_unparsable_spec_fallback(): void
|
||||
{
|
||||
$this->assertSame('20.000', WeightEstimator::estimate('10/箱', '箱', '2'), '裸数字按斤计');
|
||||
$this->assertSame('0.000', WeightEstimator::estimate('散装', '箱', '3'));
|
||||
$this->assertSame('0.000', WeightEstimator::estimate('', '箱', '3'));
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,23 @@ export async function getPurchaseCell(purchaseId: number, productId: number, sto
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换单品「已对账」标记(入库持久化,再次调用取消标记) */
|
||||
export async function togglePurchaseItemCheck(purchaseId: number, productId: number) {
|
||||
return createAxios<{ checked: boolean }>({
|
||||
url: `/purchase/order/${purchaseId}/check/${productId}`,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量设置单品「已对账」标记(全选:checked=true 批量标记,false 批量取消) */
|
||||
export async function batchPurchaseItemCheck(purchaseId: number, productIds: number[], checked: boolean) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/purchase/order/${purchaseId}/check`,
|
||||
method: 'put',
|
||||
data: { product_ids: productIds, checked },
|
||||
});
|
||||
}
|
||||
|
||||
/** 门店订单明细修改(订货量、重量、单价),自动重算价格 */
|
||||
export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) {
|
||||
return createAxios({
|
||||
|
||||
@@ -74,6 +74,8 @@ export default interface IPurchaseOrder {
|
||||
total_weight?: string;
|
||||
estimate_amount?: string;
|
||||
actual_amount?: string;
|
||||
/** 应付商品金额 = Σ 已生成门店账单的商品金额(列表接口附;未生成账单为 null) */
|
||||
bill_product_amount?: string | null;
|
||||
operator_id?: number;
|
||||
operator?: { id: number; nickname: string };
|
||||
remark?: string;
|
||||
@@ -87,6 +89,8 @@ export interface IPurchaseDetail {
|
||||
items: IPurchaseDetailRow[];
|
||||
/** 门店账单(采购单完成后按门店生成) */
|
||||
bills: IBill[];
|
||||
/** 单品「已对账」标记的商品ID列表(入库持久化) */
|
||||
checked_product_ids: number[];
|
||||
}
|
||||
|
||||
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
|
||||
@@ -143,6 +147,10 @@ export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> =
|
||||
export interface IBillPrepareStore {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
/** 客户等级名(无等级为 null) */
|
||||
level_name?: string | null;
|
||||
/** 客户等级上浮比例(售后金额折算用;无等级为 '0') */
|
||||
level_percent?: string;
|
||||
order_count: number;
|
||||
/** 商品金额(订单汇总,不可修改) */
|
||||
product_amount: string;
|
||||
|
||||
+22
-21
@@ -294,13 +294,30 @@ const ProductGoodsPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IProduct>[] = [
|
||||
// {
|
||||
// title: 'ID',
|
||||
// dataIndex: 'id',
|
||||
// hideInForm: true,
|
||||
// hideInSearch: true,
|
||||
// width: 70,
|
||||
// align: 'center',
|
||||
// },
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
title: '商品名称',
|
||||
align: 'center',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
colProps: { span: 24 },
|
||||
required: true,
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={"mb-1.5"}>{ record.name }</div>
|
||||
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
rules: [{ required: true, message: '请输入商品名称' }],
|
||||
},
|
||||
{
|
||||
title: '商品图片',
|
||||
@@ -335,22 +352,6 @@ const ProductGoodsPage: React.FC = () => {
|
||||
hideInSearch: true,
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
colProps: { span: 24 },
|
||||
required: true,
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={"mb-1.5"}>{ record.name }</div>
|
||||
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
rules: [{ required: true, message: '请输入商品名称' }],
|
||||
},
|
||||
{
|
||||
title: '商品描述',
|
||||
dataIndex: 'remark',
|
||||
|
||||
+206
-31
@@ -17,9 +17,10 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||
import {AccountBookOutlined, CheckOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
@@ -42,6 +43,7 @@ import type {
|
||||
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import {
|
||||
addPurchaseStoreItem,
|
||||
batchPurchaseItemCheck,
|
||||
exportPurchase,
|
||||
exportPurchaseStores,
|
||||
exportPurchaseSuppliers,
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
getPurchaseDetail,
|
||||
getPurchaseStoreSummary,
|
||||
removePurchaseStoreItem,
|
||||
togglePurchaseItemCheck,
|
||||
type BillGenerateStoreParams,
|
||||
type PurchaseRowUpdateParams,
|
||||
updatePurchaseRow,
|
||||
@@ -83,6 +86,10 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
|
||||
};
|
||||
|
||||
/** 售后金额按客户等级上浮折算:实收 = 输入 × (100+percent)/100(两位小数截断,与后端 calcLevelPrice 同口径) */
|
||||
const calcLevelAmount = (amount: number, percent: number): number =>
|
||||
Math.trunc(amount * (100 + percent)) / 100;
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
|
||||
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
|
||||
@@ -98,6 +105,10 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
|
||||
// 商品明细:单品「已对账」勾选标记(入库持久化,随详情接口回显)
|
||||
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set());
|
||||
const [checkAllSaving, setCheckAllSaving] = useState(false);
|
||||
|
||||
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
@@ -202,17 +213,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [billSaving, setBillSaving] = useState(false);
|
||||
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
|
||||
|
||||
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + 售后金额(可正负)
|
||||
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;售后金额 = 输入 × 客户等级上浮比例;总额 = 商品 + 配送费 + 附加 + 售后(可正负)
|
||||
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
|
||||
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
|
||||
const input = watchBillStores[index] ?? {};
|
||||
const deliveryFee = Number(input.delivery_fee ?? 0);
|
||||
const afterSale = Number(input.after_sale ?? 0);
|
||||
const levelPercent = Number(row.level_percent ?? 0);
|
||||
const afterSaleInput = Number(input.after_sale ?? 0);
|
||||
const afterSale = calcLevelAmount(afterSaleInput, levelPercent);
|
||||
const added =
|
||||
Number(input.box_num ?? 0) * Number(row.box_price) +
|
||||
Number(input.tray_num ?? 0) * Number(row.tray_price);
|
||||
return {
|
||||
added,
|
||||
afterSale,
|
||||
total: Number(row.product_amount) + deliveryFee + added + afterSale,
|
||||
};
|
||||
});
|
||||
@@ -246,6 +260,87 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
|
||||
|
||||
// 切换采购单时从详情接口恢复单品「已对账」标记(入库持久化)
|
||||
useEffect(() => {
|
||||
setCheckedItems(new Set(detail?.checked_product_ids ?? []));
|
||||
}, [detail]);
|
||||
|
||||
/** 切换单品「已对账」标记(入库持久化;乐观更新,请求失败回滚本地勾选) */
|
||||
const toggleItemChecked = async (productId: number) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
const willCheck = !checkedItems.has(productId);
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (willCheck) {
|
||||
next.add(productId);
|
||||
} else {
|
||||
next.delete(productId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await togglePurchaseItemCheck(detail.purchase.id!, productId);
|
||||
} catch {
|
||||
// 错误提示由请求封装统一弹出,这里回滚本地勾选
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (willCheck) {
|
||||
next.delete(productId);
|
||||
} else {
|
||||
next.add(productId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 商品明细当前可见行的商品ID(列筛选联动,全选仅作用于筛选后的数据) */
|
||||
const visibleProductIds = useMemo(() => summaryItems.map((row) => row.product_id), [summaryItems]);
|
||||
|
||||
/** 当前可见行是否全部已标记 */
|
||||
const allVisibleChecked =
|
||||
visibleProductIds.length > 0 && visibleProductIds.every((id) => checkedItems.has(id));
|
||||
|
||||
/** 全选/取消全选「已对账」标记(仅作用于列筛选后的可见行;乐观更新,请求失败回滚) */
|
||||
const toggleAllVisibleChecked = async () => {
|
||||
if (!detail || visibleProductIds.length === 0 || checkAllSaving) {
|
||||
return;
|
||||
}
|
||||
const willCheck = !allVisibleChecked;
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleProductIds.forEach((id) => {
|
||||
if (willCheck) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setCheckAllSaving(true);
|
||||
try {
|
||||
await batchPurchaseItemCheck(detail.purchase.id!, visibleProductIds, willCheck);
|
||||
} catch {
|
||||
// 错误提示由请求封装统一弹出,这里回滚本地勾选
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleProductIds.forEach((id) => {
|
||||
if (willCheck) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setCheckAllSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
@@ -403,7 +498,8 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
|
||||
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
|
||||
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
const res1 = await getPurchaseDetail(detail.purchase.id!);
|
||||
setDetail(res1.data.data ?? null);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
@@ -424,8 +520,9 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
|
||||
const res = await getPurchaseDetail(detail.purchase.id!);
|
||||
setDetail(res.data.data ?? null);
|
||||
message.success('订货量已更新,订货单与采购单汇总已重算');
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
@@ -562,20 +659,56 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
title: (
|
||||
<Space size={4}>
|
||||
<span>市场</span>
|
||||
<Tooltip
|
||||
title={`${allVisibleChecked ? '取消全选' : '全选对账'}(仅作用于当前筛选显示的 ${visibleProductIds.length} 行)`}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ minWidth: 16, height: 16, width: 16 }}
|
||||
icon={<CheckOutlined />}
|
||||
color={allVisibleChecked ? 'green' : 'default'}
|
||||
variant={allVisibleChecked ? 'solid' : 'outlined'}
|
||||
loading={checkAllSaving}
|
||||
disabled={visibleProductIds.length === 0}
|
||||
onClick={() => void toggleAllVisibleChecked()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
fixed: 'left',
|
||||
dataIndex: 'market',
|
||||
width: 110,
|
||||
width: 90,
|
||||
align: 'center',
|
||||
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
|
||||
filteredValue: itemColumnFilters.market ?? null,
|
||||
onFilter: (value, row) => (row.market ?? '') === value,
|
||||
render: (v) => v || '-',
|
||||
render: (v, row) => {
|
||||
// ✔ 按钮标记本单品已对账(入库持久化,随详情接口回显)
|
||||
const checked = checkedItems.has(row.product_id);
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>{v || '-'}</span>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ minWidth: 16, height: 16, width: 16 }}
|
||||
icon={<CheckOutlined />}
|
||||
color={checked ? 'green' : 'default'}
|
||||
variant={checked ? 'solid' : 'outlined'}
|
||||
onClick={() => void toggleItemChecked(row.product_id)}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
key: 'retail_price',
|
||||
width: 110,
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, row) => {
|
||||
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
|
||||
@@ -589,20 +722,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
title: '包规',
|
||||
dataIndex: 'product_spec',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
width: 90,
|
||||
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
width: 110,
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
dataIndex: 'cost_price',
|
||||
width: 110,
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`),
|
||||
},
|
||||
@@ -633,6 +766,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
key={`${row.product_id}-${store.id}-${quantity}`}
|
||||
size="small"
|
||||
min={0}
|
||||
styles={{ input: { textAlign: 'center' }, root: {width: 80} }}
|
||||
precision={0}
|
||||
className="w-full"
|
||||
defaultValue={quantity}
|
||||
@@ -973,6 +1107,19 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<Text type="secondary">未录入</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '应付商品金额',
|
||||
dataIndex: 'bill_product_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) =>
|
||||
record.bill_product_amount != null ? (
|
||||
<Text strong type="danger">¥{Number(record.bill_product_amount).toFixed(2)}</Text>
|
||||
) : (
|
||||
<Text type="secondary">未生成账单</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总件数',
|
||||
dataIndex: 'total_quantity',
|
||||
@@ -1275,6 +1422,9 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
导出
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<Text type="secondary" className="text-xs">
|
||||
已对账 {visibleProductIds.filter((id) => checkedItems.has(id)).length}/{visibleProductIds.length}
|
||||
</Text>
|
||||
{canUpdateRow && (
|
||||
<Text type="secondary" className="text-xs">
|
||||
修改保存将同步该商品全部订货明细与商品档案,成本变化时各门店单价按等级上浮自动重算
|
||||
@@ -1289,7 +1439,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
dataSource={detail.items}
|
||||
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200, y: 800 }}
|
||||
scroll={{ x: 900 + (detail?.stores.length * 100), y: 500 }}
|
||||
summary={renderSummary}
|
||||
/>
|
||||
</>
|
||||
@@ -1317,7 +1467,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
{billPrepare && (
|
||||
<>
|
||||
<div className="py-2 text-gray-500">
|
||||
每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费、周转筐/托盘数量(正数=压筐附加金额,负数=回筐抵扣金额)、售后金额(可正负,计入总金额:正数=加收,负数=售后减免)与备注(可选,随账单存档)。
|
||||
每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费、周转筐/托盘数量(正数=压筐附加金额,负数=回筐抵扣金额)、售后金额(可正负,按客户等级上浮比例折算后计入总金额:如输入 15、上浮 1% 则实收 15.15)与备注(可选,随账单存档)。
|
||||
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
|
||||
</div>
|
||||
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
|
||||
@@ -1328,9 +1478,18 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<div className="w-40 shrink-0">门店</div>
|
||||
<div className="w-28 shrink-0 text-center">商品金额</div>
|
||||
<div className="w-32 shrink-0 text-center">配送费(元)</div>
|
||||
<div className="w-32 shrink-0 text-center">周转筐(¥{billPrepare.stores[0]?.box_price ?? '0.00'}/个)</div>
|
||||
<div className="w-32 shrink-0 text-center">周转托盘(¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/个)</div>
|
||||
<div className="w-36 shrink-0 text-center">售后金额(元)</div>
|
||||
<div className="w-32 shrink-0 text-center">
|
||||
<div>周转筐</div>
|
||||
<div>(¥{billPrepare.stores[0]?.box_price ?? '0.00'}/个)</div>
|
||||
</div>
|
||||
<div className="w-32 shrink-0 text-center">
|
||||
<div>周转托盘</div>
|
||||
<div>(¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/个)</div>
|
||||
</div>
|
||||
<div className="w-50 shrink-0 text-center">
|
||||
<div>售后金额</div>
|
||||
<div>(元,按等级上浮)</div>
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-center">备注</div>
|
||||
<div className="w-28 shrink-0 text-center">附加金额</div>
|
||||
<div className="flex-1 text-center">账单总金额</div>
|
||||
@@ -1346,6 +1505,11 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
{row?.order_count ?? 0} 笔订单
|
||||
{billed && <Tag className="ml-1!" color="success">已生成</Tag>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{row?.level_name
|
||||
? `${row.level_name} +${Number(row.level_percent ?? 0)}%`
|
||||
: '无等级(不上浮)'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-28 shrink-0 text-center">
|
||||
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
|
||||
@@ -1354,7 +1518,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
className="m-0! w-32 shrink-0 px-1! flex justify-center"
|
||||
name={[field.name, 'delivery_fee']}
|
||||
rules={[{ required: true, message: '请输入配送费' }]}
|
||||
>
|
||||
@@ -1367,7 +1531,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
className="m-0! w-32 shrink-0 px-1! flex justify-center"
|
||||
name={[field.name, 'box_num']}
|
||||
rules={[{ required: true, message: '请输入周转筐数量' }]}
|
||||
>
|
||||
@@ -1379,7 +1543,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
className="m-0! w-32 shrink-0 px-1! flex justify-center"
|
||||
name={[field.name, 'tray_num']}
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
@@ -1390,17 +1554,28 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
placeholder="正压负回"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0 px-1!"
|
||||
name={[field.name, 'after_sale']}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
precision={2}
|
||||
disabled={billed}
|
||||
placeholder="可正负,计入总额"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="w-50 shrink-0 px-1! flex justify-center">
|
||||
<Space.Compact block>
|
||||
<Space.Addon>
|
||||
{/* 售后金额按客户等级上浮折算预览(实收 = 输入 × (100+上浮%)/100);已生成账单行为存档值不再折算 */}
|
||||
{(
|
||||
<div className="text-xs text-orange-500 text-center">
|
||||
实收 ¥{billPreview[field.name]?.afterSale.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</Space.Addon>
|
||||
<Form.Item className="m-0!" name={[field.name, 'after_sale']}>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
precision={2}
|
||||
prefix={'¥'}
|
||||
disabled={billed}
|
||||
placeholder="可正负,计入总额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
|
||||
</div>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0 px-1!"
|
||||
name={[field.name, 'remark']}
|
||||
|
||||
Reference in New Issue
Block a user