Compare commits
30 Commits
c3ec416a77
...
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 | |||
| d190bdaade | |||
| 95ea0714ad | |||
| 9c59f3bc99 | |||
| 6c809d893b | |||
| f8cc40a609 | |||
| d0ce897a0f |
+22
-3
@@ -47,16 +47,35 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM_ADDRESS=
|
MAIL_FROM_ADDRESS=
|
||||||
MAIL_FROM_NAME=
|
MAIL_FROM_NAME=
|
||||||
|
|
||||||
# 微信小程序(code2session 换取 openid,在线支付必需)
|
# 微信小程序(code2session 换取 openid,在线支付必需;也可在后台 系统设置→微信应用配置 维护,后台配置优先)
|
||||||
WECHAT_MINI_APPID=
|
WECHAT_MINI_APPID=
|
||||||
WECHAT_MINI_SECRET=
|
WECHAT_MINI_SECRET=
|
||||||
|
|
||||||
# 旺铺支付网关(也可在后台 系统设置→支付配置 中维护,后台配置优先)
|
# 微信公众号(网页授权换 openid,公众号 H5 JSAPI 支付用;后台 系统设置→微信应用配置 优先)
|
||||||
|
WECHAT_MP_APPID=
|
||||||
|
WECHAT_MP_SECRET=
|
||||||
|
|
||||||
|
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
|
||||||
|
PAY_ONLINE_CHANNEL=wangpu
|
||||||
|
|
||||||
|
# 旺铺支付网关(也可在后台 系统设置→旺铺支付 中维护,后台配置优先)
|
||||||
WANGPU_BASE_URL=
|
WANGPU_BASE_URL=
|
||||||
WANGPU_ORGANIZ_NO=
|
WANGPU_ORGANIZ_NO=
|
||||||
WANGPU_MER_NO=
|
WANGPU_MER_NO=
|
||||||
WANGPU_MER_CODE=
|
WANGPU_MER_CODE=
|
||||||
WANGPU_TERM_CODE=
|
WANGPU_TERM_CODE=
|
||||||
WANGPU_SIGN_KEY=
|
WANGPU_PUBLIC_KEY=
|
||||||
|
WANGPU_PRIVATE_KEY=
|
||||||
WANGPU_SUB_APPID=
|
WANGPU_SUB_APPID=
|
||||||
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
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,14 +10,26 @@ use App\Services\BillDetailService;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
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;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
||||||
* 表尾汇总商品金额/配送费/附加金额/总金额
|
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
|
||||||
*/
|
*/
|
||||||
class BillExport implements FromCollection, WithStyles
|
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;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
|
/** @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 的合计行索引 */
|
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
|
||||||
private array $mergeRows = [];
|
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 $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store)
|
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store)
|
||||||
* @param int $categoryId 一级分类ID(0=全部)
|
* @param int $categoryId 一级分类ID(0=全部)
|
||||||
@@ -76,7 +94,7 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
->all();
|
->all();
|
||||||
$rows[] = ['账单号:' . $billNoText];
|
$rows[] = ['账单号:' . $billNoText];
|
||||||
$this->specialRows[++$rowIndex] = 'scope';
|
$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';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
// 空行
|
// 空行
|
||||||
@@ -104,6 +122,8 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
(float) $item['amount'],
|
(float) $item['amount'],
|
||||||
];
|
];
|
||||||
$rowIndex++;
|
$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'];
|
$totalQuantity += (int) $item['quantity'];
|
||||||
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
|
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
|
||||||
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
||||||
@@ -112,37 +132,51 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
// 商品合计行
|
// 商品合计行
|
||||||
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
|
||||||
|
|
||||||
// 配送费/附加金额/总金额(账单级费用全额汇总,不受分类过滤影响)
|
// 配送费/附加金额/售后金额/总金额(账单级费用全额汇总,不受分类过滤影响)
|
||||||
$deliveryTotal = '0';
|
$deliveryTotal = '0';
|
||||||
$addedTotal = '0';
|
$addedTotal = '0';
|
||||||
|
$afterSaleTotal = '0';
|
||||||
$boxNum = 0;
|
$boxNum = 0;
|
||||||
$trayNum = 0;
|
$trayNum = 0;
|
||||||
foreach ($this->bills as $bill) {
|
foreach ($this->bills as $bill) {
|
||||||
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
|
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
|
||||||
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
|
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
|
||||||
|
$afterSaleTotal = bcadd($afterSaleTotal, (string) $bill->after_sale, 2);
|
||||||
$boxNum += (int) $bill->box_num;
|
$boxNum += (int) $bill->box_num;
|
||||||
$trayNum += (int) $bill->tray_num;
|
$trayNum += (int) $bill->tray_num;
|
||||||
}
|
}
|
||||||
$grandTotal = bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2);
|
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
|
||||||
|
|
||||||
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
|
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
|
||||||
|
|
||||||
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
|
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
|
||||||
|
|
||||||
$rows[] = ['总金额(商品金额+配送费+附加金额)', '', '', '', '', '', '', (float) $grandTotal];
|
$rows[] = ['售后金额合计(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal];
|
||||||
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal];
|
||||||
|
|
||||||
|
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'grand';
|
$this->specialRows[++$rowIndex] = 'grand';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头
|
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -162,6 +196,43 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
$sheet->mergeCells('A' . $row . ':G' . $row);
|
$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 = [];
|
$styles = [];
|
||||||
foreach ($this->specialRows as $row => $type) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
@@ -178,6 +249,18 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
return $styles;
|
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 上溯取根分类(商品含软删除,保证历史账单可导出),
|
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
|
||||||
* 仅保留根分类为所选分类的行
|
* 仅保留根分类为所选分类的行
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Collection;
|
|||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
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;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,14 +24,32 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|||||||
*/
|
*/
|
||||||
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
|
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;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
||||||
private array $specialRows = [];
|
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 $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
||||||
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
||||||
@@ -70,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$this->specialRows[++$rowIndex] = 'title';
|
$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';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
// 空行
|
// 空行
|
||||||
@@ -90,16 +112,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$dateKey = $date->format('Y-m-d');
|
$dateKey = $date->format('Y-m-d');
|
||||||
$row = [$dateKey];
|
$row = [$dateKey];
|
||||||
$rowTotal = '0';
|
$rowTotal = '0';
|
||||||
|
$storeValues = [];
|
||||||
foreach ($storeIds as $storeId) {
|
foreach ($storeIds as $storeId) {
|
||||||
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
||||||
$row[] = (float) $amount;
|
$row[] = (float) $amount;
|
||||||
|
$storeValues[$storeId] = (float) $amount;
|
||||||
$rowTotal = bcadd($rowTotal, $amount, 2);
|
$rowTotal = bcadd($rowTotal, $amount, 2);
|
||||||
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
||||||
}
|
}
|
||||||
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
||||||
$row[] = (float) $rowTotal;
|
$row[] = (float) $rowTotal;
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
$rowIndex++;
|
$this->rowData[++$rowIndex] = ['stores' => $storeValues, 'total' => (float) $rowTotal];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合计行:各门店列合计 + 总计
|
// 合计行:各门店列合计 + 总计
|
||||||
@@ -110,12 +134,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$totalRow[] = (float) $grandTotal;
|
$totalRow[] = (float) $grandTotal;
|
||||||
$rows[] = $totalRow;
|
$rows[] = $totalRow;
|
||||||
$this->specialRows[++$rowIndex] = 'total';
|
$this->specialRows[++$rowIndex] = 'total';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = [
|
||||||
|
'stores' => array_map(floatval(...), $columnTotals),
|
||||||
|
'total' => (float) $grandTotal,
|
||||||
|
];
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计行加粗,冻结列头行
|
* 标题/列头/合计行加粗;全表居中 + 全边框;金额区货币格式;
|
||||||
|
* 门店列(浅蓝)/合计列(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头行
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -128,6 +158,51 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
|
$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 = [];
|
$styles = [];
|
||||||
foreach ($this->specialRows as $row => $type) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
@@ -142,4 +217,16 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
|
|
||||||
return $styles;
|
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\WithStrictNullComparison;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
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;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0),
|
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
|
||||||
* 支持按供应商筛选;行尾合计 + 门店列合计;门店列整列填充突出颜色
|
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色;
|
||||||
|
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
|
||||||
|
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
|
||||||
*/
|
*/
|
||||||
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
|
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;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary) */
|
||||||
private array $specialRows = [];
|
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;
|
private int $headerRow = 1;
|
||||||
|
|
||||||
@@ -96,10 +116,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出范围:系统全部上架商品 ∪ 本采购单有订货的商品(含已删/下架)
|
// 导出范围:系统全部未删除商品(含下架)∪ 本采购单有订货的商品(含已删)
|
||||||
$products = ProductModel::withTrashed()
|
$products = ProductModel::withTrashed()
|
||||||
->with('category:id,sort')
|
->with('category:id,sort')
|
||||||
->where('status', ProductModel::STATUS_ON)
|
->whereNull('deleted_at')
|
||||||
->orWhereIn('id', array_keys($itemGroups))
|
->orWhereIn('id', array_keys($itemGroups))
|
||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
@@ -130,10 +150,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
'product_spec' => $spec,
|
'product_spec' => $spec,
|
||||||
'unit' => (string) ($snapshot->unit ?? $product->unit),
|
'unit' => (string) ($snapshot->unit ?? $product->unit),
|
||||||
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
|
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
|
||||||
// 单价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空)
|
// 单价 = 加权平均售价 ÷ 包规数值(无订货时按成本价换算,保证始终有单价)
|
||||||
'retail_price' => $group !== null && (float) $quantity > 0
|
'retail_price' => $group !== null && (float) $quantity > 0
|
||||||
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
|
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
|
||||||
: null,
|
: $this->unitRefPrice((float) ($snapshot->cost_price ?? $product->cost_price), $spec),
|
||||||
'quantity' => (float) $quantity,
|
'quantity' => (float) $quantity,
|
||||||
'weight' => (float) ($group['weight'] ?? '0'),
|
'weight' => (float) ($group['weight'] ?? '0'),
|
||||||
'amount' => (float) $amount,
|
'amount' => (float) $amount,
|
||||||
@@ -164,7 +184,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
if ($this->supplierId > 0) {
|
if ($this->supplierId > 0) {
|
||||||
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
|
$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';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
// 空行
|
// 空行
|
||||||
@@ -185,6 +205,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
$totalAmount = '0';
|
$totalAmount = '0';
|
||||||
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
|
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
|
||||||
foreach (array_values($items) as $sort => $item) {
|
foreach (array_values($items) as $sort => $item) {
|
||||||
|
$this->rowData[++$rowIndex] = [
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'amount' => $item['amount'],
|
||||||
|
'store_quantities' => $item['store_quantities'],
|
||||||
|
];
|
||||||
$rows[] = array_merge([
|
$rows[] = array_merge([
|
||||||
$sort + 1,
|
$sort + 1,
|
||||||
$item['category'],
|
$item['category'],
|
||||||
@@ -194,12 +219,14 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
$item['product_spec'],
|
$item['product_spec'],
|
||||||
$item['unit'],
|
$item['unit'],
|
||||||
$item['cost_price'],
|
$item['cost_price'],
|
||||||
$item['retail_price'] !== null ? $item['retail_price'] : '',
|
$item['retail_price'],
|
||||||
$item['quantity'],
|
$item['quantity'],
|
||||||
$item['weight'],
|
$item['weight'],
|
||||||
$item['amount'],
|
$item['amount'],
|
||||||
], array_values($item['store_quantities']));
|
], array_map(
|
||||||
$rowIndex++;
|
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
|
||||||
|
array_values($item['store_quantities']),
|
||||||
|
));
|
||||||
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
|
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
|
||||||
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||||
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||||
@@ -208,40 +235,107 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合计行(行合计 + 门店列合计)
|
// 合计行(行合计 + 门店列合计;门店无数据显示空白)
|
||||||
$rows[] = array_merge(
|
$rows[] = array_merge(
|
||||||
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
['', '', '合计', '', '', '', '', '', '', (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->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->lastRow = $rowIndex;
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = [
|
||||||
|
'quantity' => (float) $totalQuantity,
|
||||||
|
'amount' => (float) $totalAmount,
|
||||||
|
'stores' => $storeTotals,
|
||||||
|
];
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计加粗,门店列整列填充突出颜色,冻结列头
|
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
$this->collection();
|
$this->collection();
|
||||||
|
|
||||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
$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) {
|
foreach ($widths as $index => $width) {
|
||||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 门店列整列(列头 → 合计行)填充突出颜色
|
$storeIds = array_keys($this->storeNames);
|
||||||
$storeCount = count($this->storeNames);
|
$storeCount = count($storeIds);
|
||||||
for ($i = 0; $i < $storeCount; $i++) {
|
$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);
|
$column = Coordinate::stringFromColumnIndex(13 + $i);
|
||||||
$sheet->getColumnDimension($column)->setWidth(12);
|
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||||
$sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow)
|
foreach ($this->rowData as $row => $data) {
|
||||||
->getFill()
|
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
|
||||||
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
|
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||||
->getStartColor()
|
}
|
||||||
->setARGB(self::STORE_FILL);
|
}
|
||||||
|
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
|
||||||
|
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$styles = [];
|
$styles = [];
|
||||||
@@ -259,6 +353,18 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
return $styles;
|
return $styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考)
|
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use Maatwebsite\Excel\Concerns\FromCollection;
|
|||||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
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;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,14 +20,32 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|||||||
*/
|
*/
|
||||||
class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
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;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
||||||
private array $specialRows = [];
|
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 $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PurchaseOrderModel $purchase,
|
private readonly PurchaseOrderModel $purchase,
|
||||||
private readonly StoreModel $store,
|
private readonly StoreModel $store,
|
||||||
@@ -64,6 +85,10 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
$totalWeight = '0';
|
$totalWeight = '0';
|
||||||
$totalAmount = '0';
|
$totalAmount = '0';
|
||||||
foreach (array_values($items) as $sort => $item) {
|
foreach (array_values($items) as $sort => $item) {
|
||||||
|
$this->rowData[++$rowIndex] = [
|
||||||
|
'quantity' => (int) $item['quantity'],
|
||||||
|
'amount' => (float) $item['amount'],
|
||||||
|
];
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
$sort + 1,
|
$sort + 1,
|
||||||
$item['product_name'],
|
$item['product_name'],
|
||||||
@@ -75,7 +100,6 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
(float) $item['weight'],
|
(float) $item['weight'],
|
||||||
(float) $item['amount'],
|
(float) $item['amount'],
|
||||||
];
|
];
|
||||||
$rowIndex++;
|
|
||||||
$totalQuantity += (int) $item['quantity'];
|
$totalQuantity += (int) $item['quantity'];
|
||||||
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||||
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||||
@@ -84,6 +108,8 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
// 合计行
|
// 合计行
|
||||||
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = ['quantity' => $totalQuantity, 'amount' => (float) $totalAmount];
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
@@ -94,7 +120,8 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计加粗,冻结列头
|
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -107,6 +134,47 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
->setWidth($width);
|
->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 = [];
|
$styles = [];
|
||||||
foreach ($this->specialRows as $row => $type) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
@@ -121,4 +189,16 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
|
|
||||||
return $styles;
|
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\WithStyles;
|
||||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 供应商采购明细导出 · 单市场工作表
|
* 供应商采购明细导出 · 单市场工作表
|
||||||
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
|
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
|
||||||
* 有数据的单元格(品名/汇总/门店数量)填充突出颜色并加粗
|
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色)
|
||||||
*/
|
*/
|
||||||
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
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;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header) */
|
||||||
private array $specialRows = [];
|
private array $specialRows = [];
|
||||||
|
|
||||||
/** 列头所在行索引 */
|
/** 列头所在行索引 */
|
||||||
private int $headerRow = 1;
|
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 采购单
|
* @param PurchaseOrderModel $purchase 采购单
|
||||||
@@ -53,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计
|
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
|
||||||
*/
|
*/
|
||||||
public function collection(): Collection
|
public function collection(): Collection
|
||||||
{
|
{
|
||||||
@@ -66,11 +74,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
$rows = [];
|
$rows = [];
|
||||||
$rowIndex = 0;
|
$rowIndex = 0;
|
||||||
|
|
||||||
// 标题行
|
// 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
|
||||||
$rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no];
|
$rows[] = [
|
||||||
|
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
trim((string) $this->purchase->remark),
|
||||||
|
];
|
||||||
$this->specialRows[++$rowIndex] = 'title';
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
// 空行
|
// 空行(被标题/备注合并区域覆盖)
|
||||||
$rows[] = [''];
|
$rows[] = [''];
|
||||||
$rowIndex++;
|
$rowIndex++;
|
||||||
|
|
||||||
@@ -79,35 +92,26 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
$this->specialRows[++$rowIndex] = 'header';
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
$this->headerRow = $rowIndex;
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
// 明细行(有数据的单元格记录到 dataCells:品名/汇总/门店数量)
|
// 明细行(0 数量的门店格留空不填色)
|
||||||
$totalQuantity = 0;
|
|
||||||
$storeTotals = array_fill_keys($storeIds, 0);
|
|
||||||
foreach ($this->productRows as $productRow) {
|
foreach ($this->productRows as $productRow) {
|
||||||
$line = [
|
$line = [
|
||||||
$productRow['product_name'],
|
$productRow['product_name'],
|
||||||
(int) $productRow['quantity'],
|
(int) $productRow['quantity'],
|
||||||
$this->marketLabel,
|
$this->marketLabel,
|
||||||
];
|
];
|
||||||
$filled = [1, 2, 3];
|
$storeQuantities = [];
|
||||||
foreach ($storeIds as $position => $storeId) {
|
foreach ($storeIds as $storeId) {
|
||||||
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
|
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
|
||||||
$line[] = $quantity > 0 ? $quantity : '';
|
$line[] = $quantity > 0 ? $quantity : '';
|
||||||
if ($quantity > 0) {
|
$storeQuantities[$storeId] = $quantity;
|
||||||
$filled[] = 4 + $position;
|
|
||||||
$storeTotals[$storeId] += $quantity;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$rows[] = $line;
|
$rows[] = $line;
|
||||||
$this->dataCells[++$rowIndex] = $filled;
|
$this->rowData[++$rowIndex] = [
|
||||||
$totalQuantity += (int) $productRow['quantity'];
|
'quantity' => (int) $productRow['quantity'],
|
||||||
|
'store_quantities' => $storeQuantities,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
// 合计行
|
|
||||||
$rows[] = array_merge(
|
|
||||||
['合计', $totalQuantity, ''],
|
|
||||||
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
|
|
||||||
);
|
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
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
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -126,23 +131,56 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
|
|
||||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
$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) {
|
foreach ($widths as $index => $width) {
|
||||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
}
|
}
|
||||||
$storeCount = count($this->stores);
|
$storeIds = array_map('intval', array_keys($this->stores));
|
||||||
for ($i = 0; $i < $storeCount; $i++) {
|
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
|
||||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(12);
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有数据的单元格:填充突出颜色并加粗(含品名/汇总/门店数量)
|
// 全部单元格水平/垂直居中
|
||||||
foreach ($this->dataCells as $rowIndex => $columns) {
|
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||||
foreach ($columns as $columnIndex) {
|
->getAlignment()
|
||||||
$style = $sheet->getStyle(Coordinate::stringFromColumnIndex($columnIndex) . $rowIndex);
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
$style->getFill()
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
->setFillType(Fill::FILL_SOLID)
|
|
||||||
->getStartColor()->setARGB(self::DATA_FILL);
|
// 列头行自动换行(门店列窄,门店名换行显示)
|
||||||
$style->getFont()->setBold(true);
|
$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) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||||
'header', 'summary' => ['font' => ['bold' => true]],
|
'header' => ['font' => ['bold' => true]],
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
if ($style !== []) {
|
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;
|
return $styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Client;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Http\Requests\Client\SpecialBatchAddRequest;
|
||||||
|
use App\Http\Requests\Client\SpecialFormRequest;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐商品配置(小程序首页)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/client/special', 'client.special')]
|
||||||
|
class SpecialController extends BaseController
|
||||||
|
{
|
||||||
|
protected array $searchField = [
|
||||||
|
'status' => '=',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 推荐列表(默认按排序升序;keyword 按商品名模糊搜索) */
|
||||||
|
#[GetRoute(authorize: 'query')]
|
||||||
|
public function query(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$params = $request->all();
|
||||||
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
|
$query = $this->buildSearch($params, HomeSpecialModel::query()->with('product'));
|
||||||
|
|
||||||
|
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->whereHas('product', static function ($q) use ($keyword) {
|
||||||
|
$q->where('name', 'like', '%' . $keyword . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $query->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize)
|
||||||
|
->toArray();
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量添加推荐商品(已在推荐中的自动跳过) */
|
||||||
|
#[PostRoute('/batch', 'create')]
|
||||||
|
public function batchAdd(SpecialBatchAddRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productIds = $request->validated()['product_ids'];
|
||||||
|
$exists = HomeSpecialModel::whereIn('product_id', $productIds)->pluck('product_id')->all();
|
||||||
|
$newIds = array_values(array_diff($productIds, $exists));
|
||||||
|
|
||||||
|
foreach ($newIds as $productId) {
|
||||||
|
HomeSpecialModel::create([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'added' => count($newIds),
|
||||||
|
'skipped' => count($productIds) - count($newIds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑推荐(仅排序与状态) */
|
||||||
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
|
public function update(int $id, SpecialFormRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->update($request->validated());
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除推荐 */
|
||||||
|
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||||
|
public function delete(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除推荐 */
|
||||||
|
#[DeleteRoute('/batch', 'delete')]
|
||||||
|
public function batchDelete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'ids' => 'required|array|min:1',
|
||||||
|
'ids.*' => 'integer|distinct',
|
||||||
|
], [
|
||||||
|
'ids.required' => '请选择要删除的推荐',
|
||||||
|
'ids.min' => '请选择要删除的推荐',
|
||||||
|
'ids.*.integer' => '推荐 ID 格式错误',
|
||||||
|
'ids.*.distinct' => '存在重复的推荐',
|
||||||
|
]);
|
||||||
|
HomeSpecialModel::whereIn('id', $data['ids'])->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,7 @@ class BillController extends BaseMiniController
|
|||||||
'box_price' => $bill->box_price,
|
'box_price' => $bill->box_price,
|
||||||
'tray_price' => $bill->tray_price,
|
'tray_price' => $bill->tray_price,
|
||||||
'added_amount' => $bill->added_amount,
|
'added_amount' => $bill->added_amount,
|
||||||
|
'after_sale' => $bill->after_sale,
|
||||||
'total_amount' => $bill->total_amount,
|
'total_amount' => $bill->total_amount,
|
||||||
'status' => $bill->status,
|
'status' => $bill->status,
|
||||||
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
|
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class CartController extends BaseMiniController
|
|||||||
'name' => $product->name ?? '',
|
'name' => $product->name ?? '',
|
||||||
'spec' => $product->spec ?? '',
|
'spec' => $product->spec ?? '',
|
||||||
'unit' => $product->unit ?? '',
|
'unit' => $product->unit ?? '',
|
||||||
|
'price_unit' => $product->price_unit ?? '',
|
||||||
'image' => $firstFile?->file_url ?? '',
|
'image' => $firstFile?->file_url ?? '',
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
|
|||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Services\OnlinePaymentService;
|
use App\Services\OnlinePaymentService;
|
||||||
use App\Services\WangpuPayService;
|
use App\Services\WangpuPayService;
|
||||||
|
use App\Services\WechatPayService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -15,11 +16,11 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序在线支付(旺铺网关 JSAPI)
|
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5)
|
||||||
*
|
*
|
||||||
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
|
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
|
||||||
* → 小程序 wx.requestPayment 完成支付
|
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
|
||||||
* → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账)
|
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
||||||
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
@@ -28,10 +29,14 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected OnlinePaymentService $onlinePayment,
|
protected OnlinePaymentService $onlinePayment,
|
||||||
protected WangpuPayService $wangpu,
|
protected WangpuPayService $wangpu,
|
||||||
|
protected WechatPayService $wxpay,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
|
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
|
||||||
|
*
|
||||||
|
* scene=mini(默认):小程序 wx.login code 换 openid,wx.requestPayment 调起;
|
||||||
|
* scene=mp:公众号 H5 网页授权 code 换 openid,WeixinJSBridge getBrandWCPayRequest 调起。
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
#[PostRoute('/payment/online', authorize: true)]
|
#[PostRoute('/payment/online', authorize: true)]
|
||||||
@@ -41,11 +46,13 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
'bill_ids' => 'required|array|min:1',
|
'bill_ids' => 'required|array|min:1',
|
||||||
'bill_ids.*' => 'integer|distinct',
|
'bill_ids.*' => 'integer|distinct',
|
||||||
'code' => 'required|string|max:64',
|
'code' => 'required|string|max:64',
|
||||||
|
'scene' => 'nullable|string|in:mini,mp',
|
||||||
'remark' => 'nullable|string|max:255',
|
'remark' => 'nullable|string|max:255',
|
||||||
], [
|
], [
|
||||||
'bill_ids.required' => '请选择要付款的账单',
|
'bill_ids.required' => '请选择要付款的账单',
|
||||||
'bill_ids.min' => '请选择要付款的账单',
|
'bill_ids.min' => '请选择要付款的账单',
|
||||||
'code.required' => '微信登录凭证缺失,请重新进入小程序',
|
'code.required' => '微信登录/授权凭证缺失,请重新进入',
|
||||||
|
'scene.in' => '支付场景不正确',
|
||||||
'remark.max' => '备注超过最大长度',
|
'remark.max' => '备注超过最大长度',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -56,6 +63,7 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
array_map('intval', $data['bill_ids']),
|
array_map('intval', $data['bill_ids']),
|
||||||
(string) $data['code'],
|
(string) $data['code'],
|
||||||
(string) ($data['remark'] ?? ''),
|
(string) ($data['remark'] ?? ''),
|
||||||
|
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
@@ -97,19 +105,20 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旺铺支付结果后台通知(公开路由,验签后幂等结账)
|
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
|
||||||
*
|
*
|
||||||
* 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次;
|
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5,
|
||||||
|
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
|
||||||
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||||
*/
|
*/
|
||||||
#[PostRoute('/payment/notify', authorize: false)]
|
#[PostRoute('/payment/notify', authorize: false)]
|
||||||
public function notify(Request $request): JsonResponse
|
public function notify(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$params = $request->all();
|
try {
|
||||||
|
$params = $this->wangpu->verifyNotify($request->all());
|
||||||
if (! $this->wangpu->verifyNotifySign($params)) {
|
} catch (Throwable $e) {
|
||||||
Log::warning('旺铺支付通知验签失败', ['params' => $params]);
|
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
|
||||||
return $this->notifyAck('01', '验签失败');
|
return $this->notifyAck('01', '报文验签失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +131,43 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功');
|
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)
|
* 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\StoreOrderItemModel;
|
|||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Services\BillNumberService;
|
use App\Services\BillNumberService;
|
||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
|
use App\Services\WeightEstimator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -33,6 +34,10 @@ class OrderController extends BaseMiniController
|
|||||||
public function store(MiniOrderRequest $request): JsonResponse
|
public function store(MiniOrderRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$store = $this->currentStore($request);
|
$store = $this->currentStore($request);
|
||||||
|
|
||||||
|
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
|
||||||
|
$this->assertWithinOrderTimeWindow();
|
||||||
|
|
||||||
$level = $store->level_id > 0 ? $store->level : null;
|
$level = $store->level_id > 0 ? $store->level : null;
|
||||||
if ($level === null) {
|
if ($level === null) {
|
||||||
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
|
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
|
||||||
@@ -72,6 +77,7 @@ class OrderController extends BaseMiniController
|
|||||||
|
|
||||||
$totalQuantity = '0';
|
$totalQuantity = '0';
|
||||||
$totalAmount = '0';
|
$totalAmount = '0';
|
||||||
|
$totalWeight = '0';
|
||||||
$now = now();
|
$now = now();
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($items as $row) {
|
foreach ($items as $row) {
|
||||||
@@ -85,8 +91,11 @@ class OrderController extends BaseMiniController
|
|||||||
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||||
$quantity = (string) $row['quantity'];
|
$quantity = (string) $row['quantity'];
|
||||||
$amount = bcmul($price, $quantity, 2);
|
$amount = bcmul($price, $quantity, 2);
|
||||||
|
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
|
||||||
|
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
|
||||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||||
$totalAmount = bcadd($totalAmount, $amount, 2);
|
$totalAmount = bcadd($totalAmount, $amount, 2);
|
||||||
|
$totalWeight = bcadd($totalWeight, $weight, 3);
|
||||||
|
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
@@ -97,11 +106,12 @@ class OrderController extends BaseMiniController
|
|||||||
'product_spec' => $product->spec,
|
'product_spec' => $product->spec,
|
||||||
'unit' => (string) $product->unit,
|
'unit' => (string) $product->unit,
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
|
'price_unit' => $product->price_unit,
|
||||||
'image_ids' => implode(',', (array) $product->image_ids),
|
'image_ids' => implode(',', (array) $product->image_ids),
|
||||||
'content' => (string) $product->content,
|
'content' => (string) $product->content,
|
||||||
'shelf_life' => (int) $product->shelf_life,
|
'shelf_life' => (int) $product->shelf_life,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => 0,
|
'weight' => $weight,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'cost_price' => (string) $product->cost_price,
|
'cost_price' => (string) $product->cost_price,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
@@ -115,7 +125,7 @@ class OrderController extends BaseMiniController
|
|||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
'order_date' => $now->toDateString(),
|
'order_date' => $now->toDateString(),
|
||||||
'total_quantity' => $totalQuantity,
|
'total_quantity' => $totalQuantity,
|
||||||
'total_weight' => 0,
|
'total_weight' => $totalWeight,
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'status' => StoreOrderModel::STATUS_PENDING,
|
'status' => StoreOrderModel::STATUS_PENDING,
|
||||||
'remark' => $remark,
|
'remark' => $remark,
|
||||||
@@ -315,4 +325,44 @@ class OrderController extends BaseMiniController
|
|||||||
|
|
||||||
return $this->success([], '订单已取消');
|
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\BillModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Services\BillNumberService;
|
use App\Services\BillNumberService;
|
||||||
|
use App\Services\WechatMpService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -37,10 +38,19 @@ class PaymentController extends BaseMiniController
|
|||||||
return $value;
|
return $value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 支付方式开关(Checkbox 配置自动转数组;未配置时默认全部启用)
|
||||||
|
$payMethods = site_config('pay.pay_methods');
|
||||||
|
if (! is_array($payMethods)) {
|
||||||
|
$payMethods = ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online'];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
|
'pay_methods' => array_values($payMethods),
|
||||||
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
||||||
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
||||||
'bank_info' => (string) site_config('pay.bank_info', ''),
|
'bank_info' => (string) site_config('pay.bank_info', ''),
|
||||||
|
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp)
|
||||||
|
'mp_appid' => app(WechatMpService::class)->appid(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +162,7 @@ class PaymentController extends BaseMiniController
|
|||||||
|
|
||||||
$bills = $payment->bills()
|
$bills = $payment->bills()
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
|
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$data = $payment->toArray();
|
$data = $payment->toArray();
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ 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);
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
$paginator = $query->orderBy('sort')
|
$paginator = $query->orderBy('sort')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Services\CartService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序特价推荐
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
|
class SpecialController extends BaseMiniController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 特价推荐商品列表(免登录浏览;行结构与 /mini/product/list 一致:
|
||||||
|
* 登录门店附等级价 price 与 cart_id/cart_quantity 供列表直接加减购物车;
|
||||||
|
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/special/list', authorize: false)]
|
||||||
|
public function specials(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
|
$paginator = HomeSpecialModel::query()
|
||||||
|
->where('status', HomeSpecialModel::STATUS_NORMAL)
|
||||||
|
->whereHas('product', static function ($q) {
|
||||||
|
$q->where('status', ProductModel::STATUS_ON);
|
||||||
|
})
|
||||||
|
->with('product')
|
||||||
|
->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize);
|
||||||
|
|
||||||
|
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)与购物车行
|
||||||
|
$store = $this->optionalStore($request);
|
||||||
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
|
$cartService = app(CartService::class);
|
||||||
|
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
|
||||||
|
|
||||||
|
$paginator->getCollection()->transform(
|
||||||
|
static function (HomeSpecialModel $special) use ($level, $cartRows): array {
|
||||||
|
$product = $special->product;
|
||||||
|
$row = $product->toArray();
|
||||||
|
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
|
||||||
|
$row['price'] = $level !== null
|
||||||
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
|
: null;
|
||||||
|
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00')
|
||||||
|
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
|
||||||
|
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $paginator->toArray();
|
||||||
|
$data['cart'] = $cartService->summary($store);
|
||||||
|
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
|
|||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\CustomerLevelModel;
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\PurchaseItemCheckModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
@@ -22,6 +23,7 @@ use App\Services\BillGenerateService;
|
|||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
use App\Services\PurchaseGenerateService;
|
use App\Services\PurchaseGenerateService;
|
||||||
use App\Services\PurchaseItemService;
|
use App\Services\PurchaseItemService;
|
||||||
|
use App\Services\WeightEstimator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -53,7 +55,10 @@ class PurchaseOrderController extends BaseController
|
|||||||
{
|
{
|
||||||
$params = $request->all();
|
$params = $request->all();
|
||||||
$pageSize = $params['pageSize'] ?? 10;
|
$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('purchase_date', 'desc')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize)
|
->paginate($pageSize)
|
||||||
@@ -172,6 +177,12 @@ class PurchaseOrderController extends BaseController
|
|||||||
return $row;
|
return $row;
|
||||||
}, $rows),
|
}, $rows),
|
||||||
'bills' => $bills,
|
'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,
|
'content' => (string) $product->content,
|
||||||
'shelf_life' => (int) $product->shelf_life,
|
'shelf_life' => (int) $product->shelf_life,
|
||||||
'quantity' => $quantity,
|
'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),
|
'amount' => bcmul($price, (string) $quantity, 2),
|
||||||
'cost_price' => (string) $product->cost_price,
|
'cost_price' => (string) $product->cost_price,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
@@ -609,8 +623,9 @@ class PurchaseOrderController extends BaseController
|
|||||||
->get(['id', 'store_id', 'total_amount']);
|
->get(['id', 'store_id', 'total_amount']);
|
||||||
|
|
||||||
$stores = StoreModel::withTrashed()
|
$stores = StoreModel::withTrashed()
|
||||||
|
->with('level:id,name,percent')
|
||||||
->whereIn('id', $orders->pluck('store_id')->unique())
|
->whereIn('id', $orders->pluck('store_id')->unique())
|
||||||
->get(['id', 'name'])
|
->get(['id', 'name', 'level_id'])
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$bills = BillModel::query()
|
$bills = BillModel::query()
|
||||||
@@ -628,9 +643,13 @@ class PurchaseOrderController extends BaseController
|
|||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
$bill = $bills->get((int) $storeId);
|
$bill = $bills->get((int) $storeId);
|
||||||
|
$store = $stores->get((int) $storeId);
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'store_id' => (int) $storeId,
|
'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(),
|
'order_count' => $storeOrders->count(),
|
||||||
'product_amount' => $productAmount,
|
'product_amount' => $productAmount,
|
||||||
'box_price' => $boxPrice,
|
'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()], '已标记为已对账');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class PaymentController extends BaseController
|
|||||||
|
|
||||||
$bills = $payment->bills()
|
$bills = $payment->bills()
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
|
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$data = $payment->toArray();
|
$data = $payment->toArray();
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 批量添加 验证
|
||||||
|
*/
|
||||||
|
class SpecialBatchAddRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids' => 'required|array|min:1',
|
||||||
|
'product_ids.*' => 'integer|distinct|exists:product,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids.required' => '请选择要添加的商品',
|
||||||
|
'product_ids.min' => '请选择要添加的商品',
|
||||||
|
'product_ids.*.integer' => '商品 ID 格式错误',
|
||||||
|
'product_ids.*.distinct' => '存在重复的商品',
|
||||||
|
'product_ids.*.exists' => '所选商品不存在',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 编辑 验证(仅排序与状态可编辑,商品不可更换)
|
||||||
|
*/
|
||||||
|
class SpecialFormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort' => 'nullable|integer|min:0',
|
||||||
|
'status' => 'required|integer|in:0,1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort.min' => '排序不能小于 0',
|
||||||
|
'status.in' => '状态只能是 0 或 1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ namespace App\Http\Requests\Purchase;
|
|||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后/备注,金额由系统汇总不可修改;
|
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后金额/备注,金额由系统汇总不可修改;
|
||||||
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额)
|
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额;售后金额可正负,计入总金额)
|
||||||
*/
|
*/
|
||||||
class PurchaseBillGenerateRequest extends BaseFormRequest
|
class PurchaseBillGenerateRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -20,7 +20,7 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
|
|||||||
'stores.*.delivery_fee' => 'required|numeric|min:0',
|
'stores.*.delivery_fee' => 'required|numeric|min:0',
|
||||||
'stores.*.box_num' => 'required|integer',
|
'stores.*.box_num' => 'required|integer',
|
||||||
'stores.*.tray_num' => 'required|integer',
|
'stores.*.tray_num' => 'required|integer',
|
||||||
'stores.*.after_sale' => 'nullable|string|max:255',
|
'stores.*.after_sale' => 'nullable|numeric',
|
||||||
'stores.*.remark' => 'nullable|string|max:255',
|
'stores.*.remark' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
|
|||||||
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
|
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
|
||||||
'stores.*.tray_num.required' => '周转托盘数量不能为空',
|
'stores.*.tray_num.required' => '周转托盘数量不能为空',
|
||||||
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
|
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
|
||||||
'stores.*.after_sale.max' => '售后说明最长 255 个字符',
|
'stores.*.after_sale.numeric' => '售后金额格式错误(可正负)',
|
||||||
'stores.*.remark.max' => '备注最长 255 个字符',
|
'stores.*.remark.max' => '备注最长 255 个字符',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class BillModel extends Model
|
|||||||
'tray_price' => 'decimal:2',
|
'tray_price' => 'decimal:2',
|
||||||
'added_amount' => 'decimal:2',
|
'added_amount' => 'decimal:2',
|
||||||
'total_amount' => 'decimal:2',
|
'total_amount' => 'decimal:2',
|
||||||
|
'after_sale' => 'decimal:2',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
'payment_id' => 'integer',
|
'payment_id' => 'integer',
|
||||||
'paid_at' => 'datetime:Y-m-d H:i:s',
|
'paid_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序首页特价推荐商品
|
||||||
|
*/
|
||||||
|
class HomeSpecialModel extends Model
|
||||||
|
{
|
||||||
|
/** 状态:停用 */
|
||||||
|
public const int STATUS_DISABLED = 0;
|
||||||
|
/** 状态:正常 */
|
||||||
|
public const int STATUS_NORMAL = 1;
|
||||||
|
|
||||||
|
protected $table = 'mini_home_special';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'product_id',
|
||||||
|
'sort',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'product_id' => 'integer',
|
||||||
|
'sort' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联商品
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductModel::class, 'product_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,36 @@ class ProductCategoryModel extends Model
|
|||||||
return static::buildTree($query->get($columns)->toArray());
|
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',
|
'actual_amount' => 'decimal:2',
|
||||||
'operator_id' => 'integer',
|
'operator_id' => 'integer',
|
||||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
'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');
|
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',
|
'address',
|
||||||
'payment_cycle_days',
|
'payment_cycle_days',
|
||||||
'openid',
|
'openid',
|
||||||
|
'mp_openid',
|
||||||
'status',
|
'status',
|
||||||
'remark',
|
'remark',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class StoreOrderItemModel extends Model
|
|||||||
'product_spec',
|
'product_spec',
|
||||||
'unit',
|
'unit',
|
||||||
'price',
|
'price',
|
||||||
|
'price_unit',
|
||||||
'image_ids',
|
'image_ids',
|
||||||
'content',
|
'content',
|
||||||
'shelf_life',
|
'shelf_life',
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ class BillDetailService
|
|||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => $weight,
|
'weight' => $weight,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
|
'spec' => $product->spec,
|
||||||
|
'price_unit' => $product->price_unit,
|
||||||
'image_ids' => (array) $first->image_ids,
|
'image_ids' => (array) $first->image_ids,
|
||||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||||
'product_sort' => (int) ($product->sort ?? 9999),
|
'product_sort' => (int) ($product->sort ?? 9999),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\ContainerReturnModel;
|
use App\Models\ContainerReturnModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
@@ -20,8 +21,10 @@ use Throwable;
|
|||||||
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
||||||
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
||||||
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
||||||
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
|
||||||
* 5. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
* 可正负:正数=加收,负数=售后减免)
|
||||||
|
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
||||||
|
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
||||||
*/
|
*/
|
||||||
readonly class BillGenerateService
|
readonly class BillGenerateService
|
||||||
{
|
{
|
||||||
@@ -31,7 +34,7 @@ readonly class BillGenerateService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PurchaseOrderModel $purchase 已完成采购单
|
* @param PurchaseOrderModel $purchase 已完成采购单
|
||||||
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int, after_sale?: string, remark?: string}> $stores 按门店提交的配送费/周转筐/托盘数量与售后/备注
|
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int, after_sale?: string|int|float, remark?: string}> $stores 按门店提交的配送费/周转筐/托盘数量与售后金额/备注
|
||||||
* @param int $operatorId 生成人(后台系统用户ID)
|
* @param int $operatorId 生成人(后台系统用户ID)
|
||||||
* @return BillModel[] 生成的账单列表
|
* @return BillModel[] 生成的账单列表
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
@@ -81,6 +84,14 @@ readonly class BillGenerateService
|
|||||||
$boxPrice = (string) site_config('services.box_amount', 0);
|
$boxPrice = (string) site_config('services.box_amount', 0);
|
||||||
$trayPrice = (string) site_config('services.tray_amount', 0);
|
$trayPrice = (string) site_config('services.tray_amount', 0);
|
||||||
$billDate = now()->toDateString();
|
$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 = [];
|
$bills = [];
|
||||||
foreach ($ordersByStore as $storeId => $storeOrders) {
|
foreach ($ordersByStore as $storeId => $storeOrders) {
|
||||||
$row = $submitted[(int) $storeId];
|
$row = $submitted[(int) $storeId];
|
||||||
@@ -95,7 +106,12 @@ readonly class BillGenerateService
|
|||||||
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
|
// 售后金额按客户等级上浮比例折算(输入 15、上浮 1% → 实收 15.15;负数同比例放大)
|
||||||
|
$afterSale = CustomerLevelModel::calcLevelPrice(
|
||||||
|
(string) ($row['after_sale'] ?? '0'),
|
||||||
|
$levelPercents[(int) $storeId] ?? '0',
|
||||||
|
);
|
||||||
|
$totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
|
||||||
|
|
||||||
$bill = BillModel::create([
|
$bill = BillModel::create([
|
||||||
'bill_no' => $this->billNumberService->make('ZD'),
|
'bill_no' => $this->billNumberService->make('ZD'),
|
||||||
@@ -111,7 +127,7 @@ readonly class BillGenerateService
|
|||||||
'added_amount' => $addedAmount,
|
'added_amount' => $addedAmount,
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'operator_id' => $operatorId,
|
'operator_id' => $operatorId,
|
||||||
'after_sale' => (string) ($row['after_sale'] ?? ''),
|
'after_sale' => $afterSale,
|
||||||
'remark' => (string) ($row['remark'] ?? ''),
|
'remark' => (string) ($row['remark'] ?? ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,42 +13,80 @@ use Illuminate\Support\Facades\Log;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在线支付编排服务(旺铺网关)
|
* 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付)
|
||||||
*
|
*
|
||||||
* 链路:门店小程序选择账单合并付款
|
* 链路:门店小程序选择账单合并付款
|
||||||
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2)
|
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2)
|
||||||
* → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment
|
* → 按当前渠道(site_config pay.online_channel)走旺铺统一下单或微信 JSAPI 下单
|
||||||
|
* → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||||
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
|
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
|
||||||
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
|
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
|
||||||
*
|
*
|
||||||
|
* 已创建的支付单按自身 pay_method 固定在原渠道查询/结账(渠道切换不影响进行中的支付单)。
|
||||||
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
|
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
|
||||||
*/
|
*/
|
||||||
class OnlinePaymentService
|
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(
|
public function __construct(
|
||||||
protected BillNumberService $billNumber,
|
protected BillNumberService $billNumber,
|
||||||
protected WangpuPayService $wangpu,
|
protected WangpuPayService $wangpu,
|
||||||
|
protected WechatPayService $wxpay,
|
||||||
protected WechatMiniService $wechat,
|
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 array<int, int> $billIds 合并付款的账单ID
|
||||||
* @param string $code 小程序 wx.login() 返回的登录凭证
|
* @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分)
|
||||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
|
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
|
||||||
|
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
||||||
* @throws Throwable
|
* @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 并绑定到门店(下次可直接复用)
|
$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);
|
$openid = $this->wechat->code2session($code);
|
||||||
if ((string) $store->openid !== $openid) {
|
if ((string) $store->openid !== $openid) {
|
||||||
$store->openid = $openid;
|
$store->openid = $openid;
|
||||||
$store->save();
|
$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()
|
$bills = BillModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
->whereIn('id', $billIds)
|
->whereIn('id', $billIds)
|
||||||
@@ -79,7 +117,7 @@ class OnlinePaymentService
|
|||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'pay_type' => PaymentModel::TYPE_ONLINE,
|
'pay_type' => PaymentModel::TYPE_ONLINE,
|
||||||
'pay_method' => PaymentModel::METHOD_WANGPU,
|
'pay_method' => $payMethod,
|
||||||
'voucher_ids' => '',
|
'voucher_ids' => '',
|
||||||
'status' => PaymentModel::STATUS_PENDING,
|
'status' => PaymentModel::STATUS_PENDING,
|
||||||
'openid' => $openid,
|
'openid' => $openid,
|
||||||
@@ -93,11 +131,20 @@ class OnlinePaymentService
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$gatewayData = $this->wangpu->createOrder([
|
$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,
|
'mer_order_id' => $payment->payment_no,
|
||||||
'order_amt' => (string) $payment->amount,
|
'order_amt' => (string) $payment->amount,
|
||||||
'open_id' => $openid,
|
'open_id' => $openid,
|
||||||
'sub_appid' => $this->subAppid(),
|
'sub_appid' => $this->subAppid($scene),
|
||||||
|
'payway_code' => $this->wangpu->paywayCode($scene),
|
||||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||||
]);
|
]);
|
||||||
@@ -107,15 +154,22 @@ class OnlinePaymentService
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($channel === self::CHANNEL_WANGPU) {
|
||||||
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||||
|
}
|
||||||
$payment->pay_params = $gatewayData;
|
$payment->pay_params = $gatewayData;
|
||||||
$payment->save();
|
$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 通知报文(已验签)
|
* @param array<string, mixed> $params 通知报文(已验签)
|
||||||
* @return bool 本次是否执行了结账(false = 重复通知)
|
* @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
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function queryAndSettle(PaymentModel $payment): array
|
public function queryAndSettle(PaymentModel $payment): array
|
||||||
@@ -156,6 +247,10 @@ class OnlinePaymentService
|
|||||||
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
|
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);
|
$data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id);
|
||||||
$orderStatus = (int) ($data['order_status'] ?? -1);
|
$orderStatus = (int) ($data['order_status'] ?? -1);
|
||||||
|
|
||||||
@@ -172,6 +267,30 @@ class OnlinePaymentService
|
|||||||
return ['payment' => $payment->fresh(), 'order_status' => $orderStatus];
|
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,
|
'status' => BillModel::STATUS_PAID,
|
||||||
'paid_at' => $paidAt,
|
'paid_at' => $paidAt,
|
||||||
'paid_operator_id' => 0,
|
'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 === '') {
|
if ($subAppid === '') {
|
||||||
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
||||||
}
|
}
|
||||||
if ($subAppid === '') {
|
if ($subAppid === '') {
|
||||||
$subAppid = (string) config('services.wechat.mini.appid', '');
|
$subAppid = $this->wechat->appid();
|
||||||
}
|
}
|
||||||
return trim($subAppid);
|
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),
|
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
|
||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
|
// 参考总重量 = Σ 明细参考重量(下单时按订货量 × 规格预填)
|
||||||
|
$totalWeight = $items->reduce(
|
||||||
|
static fn (string $carry, $item): string => bcadd($carry, (string) $item->weight, 3),
|
||||||
|
'0'
|
||||||
|
);
|
||||||
|
|
||||||
$purchase = PurchaseOrderModel::create([
|
$purchase = PurchaseOrderModel::create([
|
||||||
'purchase_no' => $this->billNumberService->make('PO'),
|
'purchase_no' => $this->billNumberService->make('PO'),
|
||||||
'purchase_date' => $date,
|
'purchase_date' => $date,
|
||||||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||||||
'total_quantity' => $totalQuantity,
|
'total_quantity' => $totalQuantity,
|
||||||
'total_weight' => 0,
|
'total_weight' => $totalWeight,
|
||||||
'estimate_amount' => $estimateAmount,
|
'estimate_amount' => $estimateAmount,
|
||||||
'actual_amount' => 0,
|
'actual_amount' => 0,
|
||||||
'operator_id' => $operatorId,
|
'operator_id' => $operatorId,
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ namespace App\Services;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签)
|
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密)
|
||||||
*
|
*
|
||||||
* 加签规则(请求与回调一致):
|
* 通讯协议(与官方示例 demo/IndexController.php 一致):
|
||||||
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str,
|
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data;
|
||||||
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
|
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature;
|
||||||
|
* - 整体以 JSON 信封 POST:{serialNo, version, timestamp, data, signature, extras, organizNo};
|
||||||
|
* 应答报文反向解密:商户私钥解 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
|
class WangpuPayService
|
||||||
{
|
{
|
||||||
@@ -27,18 +34,20 @@ class WangpuPayService
|
|||||||
public const int ORDER_STATUS_PAID = 1;
|
public const int ORDER_STATUS_PAID = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码)
|
* 统一下单(小程序 JSAPI)
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
||||||
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
|
* @return array<string, mixed> 网关 data 解密后的业务报文(含 order_id/调起支付参数)
|
||||||
* @throws RepositoryException 网关应答失败
|
* @throws RepositoryException 网关应答失败
|
||||||
*/
|
*/
|
||||||
public function createOrder(array $params): array
|
public function createOrder(array $params): array
|
||||||
{
|
{
|
||||||
return $this->request('/industrial/payment/order', array_merge([
|
return $this->request('/industrial/payment/order', array_merge([
|
||||||
|
'mer_no' => $this->config('mer_no'),
|
||||||
'mer_code' => $this->config('mer_code'),
|
'mer_code' => $this->config('mer_code'),
|
||||||
'term_code' => $this->config('term_code'),
|
'term_code' => $this->config('term_code'),
|
||||||
'payway_code' => $this->config('payway_code'),
|
'payway_code' => $this->config('payway_code'),
|
||||||
|
'organiz_no' => $this->config('organiz_no'),
|
||||||
], $params));
|
], $params));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +56,15 @@ class WangpuPayService
|
|||||||
*
|
*
|
||||||
* @param string $merOrderId 商户唯一订单号(本系统 payment_no)
|
* @param string $merOrderId 商户唯一订单号(本系统 payment_no)
|
||||||
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
|
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
|
||||||
* @return array<string, mixed> 网关 data 节点
|
* @return array<string, mixed> 网关 data 解密后的业务报文
|
||||||
* @throws RepositoryException 网关应答失败
|
* @throws RepositoryException 网关应答失败
|
||||||
*/
|
*/
|
||||||
public function queryOrder(string $merOrderId, string $orderId = ''): array
|
public function queryOrder(string $merOrderId, string $orderId = ''): array
|
||||||
{
|
{
|
||||||
$params = ['mer_order_id' => $merOrderId];
|
$params = [
|
||||||
|
'organiz_no' => $this->config('organiz_no'),
|
||||||
|
'mer_order_id' => $merOrderId,
|
||||||
|
];
|
||||||
if ($orderId !== '') {
|
if ($orderId !== '') {
|
||||||
$params['order_id'] = $orderId;
|
$params['order_id'] = $orderId;
|
||||||
}
|
}
|
||||||
@@ -60,44 +72,41 @@ class WangpuPayService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验后台通知签名(支付成功/退款成功通知通用)
|
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $params 通知报文全量参数(含 sign)
|
* @param array<string, mixed> $params 通知表单报文(含 sign)
|
||||||
|
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
|
||||||
|
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
|
||||||
*/
|
*/
|
||||||
public function verifyNotifySign(array $params): bool
|
public function verifyNotify(array $params): array
|
||||||
{
|
{
|
||||||
$sign = (string) ($params['sign'] ?? '');
|
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
|
||||||
if ($sign === '') {
|
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
|
||||||
return false;
|
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
|
||||||
|
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
|
||||||
|
throw new RepositoryException('通知报文验签失败');
|
||||||
}
|
}
|
||||||
return hash_equals($this->sign($params), strtoupper($sign));
|
return $params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接,
|
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
|
||||||
* 末尾拼接 &key={SignKey} 后 MD5 转大写
|
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $params
|
* @param array<string, mixed> $params 通知报文
|
||||||
|
* @throws RepositoryException 加签Key未配置
|
||||||
*/
|
*/
|
||||||
public function sign(array $params): string
|
protected function sign(array $params): string
|
||||||
{
|
{
|
||||||
unset($params['sign']);
|
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));
|
||||||
|
|
||||||
$pairs = [];
|
$signKey = $this->config('sign_key');
|
||||||
foreach ($params as $key => $value) {
|
if ($signKey === '') {
|
||||||
if ($value === null || $value === '') {
|
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (is_array($value) || is_object($value)) {
|
return strtoupper(md5($str . '&key=' . $signKey));
|
||||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
$pairs[(string) $key] = (string) $key . '=' . (string) $value;
|
|
||||||
}
|
|
||||||
ksort($pairs, SORT_STRING);
|
|
||||||
|
|
||||||
$signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key');
|
|
||||||
|
|
||||||
return strtoupper(md5($signStr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,15 +114,29 @@ class WangpuPayService
|
|||||||
*/
|
*/
|
||||||
public function notifyUrl(): string
|
public function notifyUrl(): string
|
||||||
{
|
{
|
||||||
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
|
return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
|
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code)
|
||||||
|
*/
|
||||||
|
public function paywayCode(string $scene = 'mini'): string
|
||||||
|
{
|
||||||
|
if ($scene === 'mp') {
|
||||||
|
$mpPayway = $this->config('mp_payway_code');
|
||||||
|
if ($mpPayway !== '') {
|
||||||
|
return $mpPayway;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->config('payway_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $params 接口业务参数
|
* @param array<string, mixed> $params 接口业务参数
|
||||||
* @return array<string, mixed> 应答 data 节点
|
* @return array<string, mixed> 应答 data 解密后的业务报文
|
||||||
* @throws RepositoryException 通讯失败或应答码非成功
|
* @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败
|
||||||
*/
|
*/
|
||||||
protected function request(string $path, array $params): array
|
protected function request(string $path, array $params): array
|
||||||
{
|
{
|
||||||
@@ -122,27 +145,174 @@ class WangpuPayService
|
|||||||
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
|
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = array_merge([
|
$envelope = $this->encryptEnvelope($params);
|
||||||
'organiz_no' => $this->config('organiz_no'),
|
Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]);
|
||||||
'mer_no' => $this->config('mer_no'),
|
|
||||||
], $params);
|
$response = Http::timeout(15)->post($baseUrl . $path, $envelope);
|
||||||
// 空值参数不下送(与网关签名口径一致)
|
Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
|
||||||
$body = array_filter($body, static fn ($value): bool => $value !== null && $value !== '');
|
|
||||||
$body['sign'] = $this->sign($body);
|
|
||||||
|
|
||||||
$response = Http::asForm()->timeout(15)->post($baseUrl . $path, $body);
|
|
||||||
$result = $response->json();
|
$result = $response->json();
|
||||||
if (! is_array($result)) {
|
if (! is_array($result)) {
|
||||||
Log::error('旺铺网关应答异常', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
|
Log::driver('pay')->error('旺铺网关应答异常', [
|
||||||
|
'path' => $path,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
||||||
}
|
}
|
||||||
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
|
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
|
||||||
Log::warning('旺铺网关应答失败', ['path' => $path, 'response' => $result]);
|
Log::driver('pay')->warning('旺铺网关应答失败', [
|
||||||
|
'path' => $path,
|
||||||
|
'response' => $result,
|
||||||
|
]);
|
||||||
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $result['data'] ?? [];
|
try {
|
||||||
return is_array($data) ? $data : [];
|
$data = $this->decryptEnvelope($result);
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]);
|
||||||
|
throw new RepositoryException('支付网关应答解密失败,请联系管理员');
|
||||||
|
}
|
||||||
|
Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 业务参数
|
||||||
|
* @return array<string, string> 请求信封
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function encryptEnvelope(array $params): array
|
||||||
|
{
|
||||||
|
$key = Str::random(16); // AES 密钥(16 字节)
|
||||||
|
return [
|
||||||
|
'serialNo' => Str::random(32), // 请求流水号
|
||||||
|
'version' => '1.0',
|
||||||
|
'timestamp' => now()->format('YmdHis'),
|
||||||
|
'data' => urlencode($this->aesEncrypt($params, $key)),
|
||||||
|
'signature' => urlencode($this->rsaPublicEncrypt($key)),
|
||||||
|
'extras' => '',
|
||||||
|
'organizNo' => $this->config('organiz_no'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $envelope
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function decryptEnvelope(array $envelope): array
|
||||||
|
{
|
||||||
|
$data = (string) ($envelope['data'] ?? '');
|
||||||
|
$signature = (string) ($envelope['signature'] ?? '');
|
||||||
|
if ($data === '' || $signature === '') {
|
||||||
|
throw new RepositoryException('报文缺少 data/signature 字段');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature));
|
||||||
|
$decoded = json_decode($plain, true);
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
throw new RepositoryException('报文 JSON 解析失败');
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-ECB 加密(base64 输出,与官方示例算法一致)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function aesEncrypt(array $data, string $key): string
|
||||||
|
{
|
||||||
|
$cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RepositoryException('报文 AES 加密失败');
|
||||||
|
}
|
||||||
|
return base64_encode($cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-ECB 解密(base64 输入,与官方示例算法一致)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function aesDecrypt(string $cipherBase64, string $key): string
|
||||||
|
{
|
||||||
|
$plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||||
|
if (! is_string($plain)) {
|
||||||
|
throw new RepositoryException('报文 AES 解密失败');
|
||||||
|
}
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 平台公钥加密(base64 输出,用于加密 AES 密钥)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function rsaPublicEncrypt(string $data): string
|
||||||
|
{
|
||||||
|
if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) {
|
||||||
|
throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置');
|
||||||
|
}
|
||||||
|
return base64_encode($encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 商户私钥解密(base64 输入,用于解出 AES 密钥)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function rsaPrivateDecrypt(string $cipherBase64): string
|
||||||
|
{
|
||||||
|
$ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE'));
|
||||||
|
if (! $ok || ! is_string($decrypted)) {
|
||||||
|
throw new RepositoryException('商户私钥解密失败,请检查密钥配置');
|
||||||
|
}
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符)
|
||||||
|
*
|
||||||
|
* 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 未配置或密钥无法解析
|
||||||
|
*/
|
||||||
|
protected function pemKey(string $configKey, string $kind): string
|
||||||
|
{
|
||||||
|
$name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥';
|
||||||
|
$body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey));
|
||||||
|
if ($body === '') {
|
||||||
|
throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n"
|
||||||
|
. wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----";
|
||||||
|
|
||||||
|
if ($kind === 'PUBLIC') {
|
||||||
|
// 公钥兼容 SPKI(PUBLIC KEY)与 PKCS#1(RSA PUBLIC KEY)两种格式
|
||||||
|
foreach (['PUBLIC', 'RSA PUBLIC'] as $header) {
|
||||||
|
if (openssl_pkey_get_public($wrap($header)) !== false) {
|
||||||
|
return $wrap($header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false;
|
||||||
|
throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pem = $wrap('PRIVATE');
|
||||||
|
if (openssl_pkey_get_private($pem) === false) {
|
||||||
|
$isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false;
|
||||||
|
throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
|
||||||
|
}
|
||||||
|
return $pem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +320,7 @@ class WangpuPayService
|
|||||||
*/
|
*/
|
||||||
protected function config(string $key): string
|
protected function config(string $key): string
|
||||||
{
|
{
|
||||||
$value = site_config('pay.wangpu_' . $key, '');
|
$value = site_config('wangpu.' . $key, '');
|
||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
$value = config('services.wangpu.' . $key, '');
|
$value = config('services.wangpu.' . $key, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ class WechatMiniService
|
|||||||
*/
|
*/
|
||||||
public function code2session(string $code): string
|
public function code2session(string $code): string
|
||||||
{
|
{
|
||||||
$appid = trim((string) config('services.wechat.mini.appid'));
|
$appid = $this->appid();
|
||||||
$secret = trim((string) config('services.wechat.mini.secret'));
|
$secret = $this->config('mini_secret');
|
||||||
if ($appid === '' || $secret === '') {
|
if ($appid === '' || $secret === '') {
|
||||||
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
|
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = Http::timeout(10)->get('https://api.weixin.qq.com/sns/jscode2session', [
|
$result = Http::timeout(10)->withOptions([
|
||||||
|
'verify' => false, // 禁用 SSL 证书验证
|
||||||
|
])->get('https://api.weixin.qq.com/sns/jscode2session', [
|
||||||
'appid' => $appid,
|
'appid' => $appid,
|
||||||
'secret' => $secret,
|
'secret' => $secret,
|
||||||
'js_code' => $code,
|
'js_code' => $code,
|
||||||
@@ -43,4 +45,28 @@ class WechatMiniService
|
|||||||
|
|
||||||
return $openid;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -17,7 +17,8 @@
|
|||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"predis/predis": "2.0",
|
"predis/predis": "2.0",
|
||||||
"w7corp/easywechat": "^6.19"
|
"w7corp/easywechat": "^6.19",
|
||||||
|
"ext-openssl": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.0",
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ return [
|
|||||||
'level' => 'debug',
|
'level' => 'debug',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'pay' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/pay.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+35
-3
@@ -45,17 +45,31 @@ return [
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
||||||
|
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
|
||||||
|
* 公众号(mp):网页授权换 openid、公众号 JSAPI 支付使用,后台 site_config wechat.mp_appid / wechat.mp_secret 优先
|
||||||
*/
|
*/
|
||||||
'wechat' => [
|
'wechat' => [
|
||||||
'mini' => [
|
'mini' => [
|
||||||
'appid' => env('WECHAT_MINI_APPID', ''),
|
'appid' => env('WECHAT_MINI_APPID', ''),
|
||||||
'secret' => env('WECHAT_MINI_SECRET', ''),
|
'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 / 交易查询 / 后台通知)
|
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
|
||||||
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置
|
* 优先读取后台「系统设置 → 旺铺支付」(site_config wangpu.*),为空时回退到这里的 env 配置
|
||||||
*/
|
*/
|
||||||
'wangpu' => [
|
'wangpu' => [
|
||||||
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
|
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
|
||||||
@@ -63,8 +77,26 @@ return [
|
|||||||
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
|
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
|
||||||
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
|
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
|
||||||
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
|
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
|
||||||
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 Key(MD5 加签)
|
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
|
||||||
|
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
|
||||||
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
'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)
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ return new class extends Migration
|
|||||||
$table->string('key', 50)->comment('设置项标示');
|
$table->string('key', 50)->comment('设置项标示');
|
||||||
$table->string('title', 50)->comment('设置标题');
|
$table->string('title', 50)->comment('设置标题');
|
||||||
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
|
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
|
||||||
$table->string('values', 255)->nullable()->default('')->comment('设置值');
|
$table->string('values', 2000)->nullable()->default('')->comment('设置值');
|
||||||
$table->string('type', 50)->comment('设置类型');
|
$table->string('type', 50)->comment('设置类型');
|
||||||
$table->string('options', 500)->nullable()->comment('options配置');
|
$table->string('options', 500)->nullable()->comment('options配置');
|
||||||
$table->string('props', 500)->nullable()->comment('props配置');
|
$table->string('props', 500)->nullable()->comment('props配置');
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ return new class extends Migration
|
|||||||
$table->string('address', 255)->default('')->comment('门店地址');
|
$table->string('address', 255)->default('')->comment('门店地址');
|
||||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||||
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
|
$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->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ return new class extends Migration
|
|||||||
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
||||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||||
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
||||||
|
$table->string('price_unit', 20)->default('')->comment('单价单位');
|
||||||
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
||||||
$table->text('content')->comment('商品图文详情');
|
$table->text('content')->comment('商品图文详情');
|
||||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ return new class extends Migration
|
|||||||
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
|
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
|
||||||
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
|
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
|
||||||
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
|
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
|
||||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额');
|
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额');
|
||||||
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
|
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
|
||||||
$table->integer('payment_id')->default(0)->comment('关联支付记录ID(0=未发起支付)');
|
$table->integer('payment_id')->default(0)->comment('关联支付记录ID(0=未发起支付)');
|
||||||
$table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)');
|
$table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)');
|
||||||
@@ -36,7 +36,7 @@ return new class extends Migration
|
|||||||
$table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)');
|
$table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)');
|
||||||
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID)');
|
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID)');
|
||||||
$table->string('remark', 255)->nullable()->default('')->comment('备注(生成账单时按门店填写)');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注(生成账单时按门店填写)');
|
||||||
$table->string('after_sale', 255)->nullable()->default('')->comment('售后说明(生成账单时按门店填写)');
|
$table->decimal('after_sale', 10, 2)->default(0)->comment('售后金额(生成账单时按门店填写,可正负,计入总金额)');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
|
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
|
||||||
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
|
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* 小程序首页特价推荐:后台批量选择商品标记为推荐,小程序端按推荐排序展示
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('mini_home_special')) {
|
||||||
|
Schema::create('mini_home_special', function (Blueprint $table) {
|
||||||
|
$table->increments('id')->comment('推荐ID');
|
||||||
|
$table->integer('product_id')->comment('商品ID');
|
||||||
|
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||||
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique('product_id', 'uk_mini_home_special_product');
|
||||||
|
$table->index(['status', 'sort'], 'idx_mini_home_special_status_sort');
|
||||||
|
$table->comment('小程序首页特价推荐商品');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mini_home_special');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* 采购单单品对账标记:商品明细「已对账」勾选入库持久化(替代原前端 localStorage 本地标记)
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('purchase_item_check')) {
|
||||||
|
Schema::create('purchase_item_check', function (Blueprint $table) {
|
||||||
|
$table->increments('id')->comment('标记ID');
|
||||||
|
$table->integer('purchase_id')->comment('采购单ID');
|
||||||
|
$table->integer('product_id')->comment('商品ID');
|
||||||
|
$table->integer('operator_id')->default(0)->comment('标记人(后台系统用户ID)');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['purchase_id', 'product_id'], 'purchase_item_check_unique');
|
||||||
|
$table->comment('采购单单品对账标记表(商品明细「已对账」勾选)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('purchase_item_check');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -271,6 +271,18 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => 'route',
|
||||||
|
'key' => 'client.special',
|
||||||
|
'name' => '特价推荐',
|
||||||
|
'path' => '/client/special',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'rule', 'key' => 'client.special.query', 'name' => '查询'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.create', 'name' => '新增'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.update', 'name' => '编辑'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.delete', 'name' => '删除'],
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ class SysDataSeeder extends Seeder
|
|||||||
DB::table('sys_site_config_group')->insert([
|
DB::table('sys_site_config_group')->insert([
|
||||||
['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date],
|
['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' => 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([
|
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,],
|
['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,17 +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' => 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' => 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' => 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' => 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' => 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' => 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' => 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' => 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' => 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],
|
||||||
['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],
|
// 旺铺支付(wangpu):旺铺支付网关
|
||||||
['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' => 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' => 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' => 13, 'group_id' => 5, 'key' => 'organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 Key(SignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, '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' => 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' => 15, 'group_id' => 5, 'key' => 'mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, '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' => 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([
|
DB::table('sys_dict')->insert([
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
# 小程序接口文档:在线支付(旺铺网关 JSAPI)
|
|
||||||
|
|
||||||
> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单,
|
|
||||||
> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账,
|
|
||||||
> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。
|
|
||||||
>
|
|
||||||
> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。
|
|
||||||
|
|
||||||
## 整体流程
|
|
||||||
|
|
||||||
```
|
|
||||||
小程序 后端 旺铺网关 微信
|
|
||||||
│ wx.login → code │ │ │
|
|
||||||
│ POST /mini/payment/online (bill_ids, code) │ │
|
|
||||||
│────────────────────▶│ code2session 换 openid │──────────────────▶│
|
|
||||||
│ │ 创建支付单+锁定账单 │ │
|
|
||||||
│ │ 统一下单(mer_order_id=支付单号) ──────────▶│
|
|
||||||
│ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│
|
|
||||||
│◀────────────────────│ │ │
|
|
||||||
│ wx.requestPayment(pay_params) ─────────────────────────────────▶│
|
|
||||||
│ │ POST /mini/payment/notify(支付成功通知) │
|
|
||||||
│ │◀───────────────────────│ │
|
|
||||||
│ │ 验签 → 幂等结账 → 应答 {"code":"00"} │
|
|
||||||
│ GET .../query 主动同步(兜底) │ │
|
|
||||||
│────────────────────▶│ 交易查询 → 已支付则结账 │ │
|
|
||||||
```
|
|
||||||
|
|
||||||
## 通用约定
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| 鉴权 | 门店 token:`Authorization: Bearer <token>`(登录见 `/mini/auth/login`) |
|
|
||||||
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
|
|
||||||
| 金额单位 | 元,字符串/数字两位小数(如 `150.50`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 发起在线支付(合并账单下单)
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| 请求方式 | `POST` |
|
|
||||||
| 路径 | `/mini/payment/online` |
|
|
||||||
| 鉴权 | 门店 token |
|
|
||||||
|
|
||||||
### 请求参数
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 |
|
|
||||||
| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) |
|
|
||||||
| `remark` | string | 否 | 付款备注,最长 255 字 |
|
|
||||||
|
|
||||||
### 校验规则(失败均返回 `success:false`)
|
|
||||||
|
|
||||||
- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定;
|
|
||||||
- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号;
|
|
||||||
- 合计金额必须大于 0;
|
|
||||||
- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单;
|
|
||||||
- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。
|
|
||||||
|
|
||||||
### 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"msg": "下单成功,请调起支付",
|
|
||||||
"data": {
|
|
||||||
"id": 12,
|
|
||||||
"payment_no": "ZF202608270001",
|
|
||||||
"amount": "150.50",
|
|
||||||
"pay_params": {
|
|
||||||
"order_id": "202608271201444525348059",
|
|
||||||
"tradeNo": "2021082722001407831438768160",
|
|
||||||
"user_openid": "oXxx123"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字段说明
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 |
|
|
||||||
| `amount` | 应付金额(= 所选账单总额合计,元) |
|
|
||||||
| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 |
|
|
||||||
|
|
||||||
### 小程序端调用示例
|
|
||||||
|
|
||||||
```js
|
|
||||||
// 1. 获取登录凭证
|
|
||||||
const { code } = await wx.login();
|
|
||||||
|
|
||||||
// 2. 后端下单
|
|
||||||
const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code });
|
|
||||||
const { payment_no, pay_params } = res.data;
|
|
||||||
|
|
||||||
// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准)
|
|
||||||
await wx.requestPayment({
|
|
||||||
timeStamp: pay_params.timeStamp,
|
|
||||||
nonceStr: pay_params.nonceStr,
|
|
||||||
package: pay_params.package,
|
|
||||||
signType: pay_params.signType || 'RSA',
|
|
||||||
paySign: pay_params.paySign,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. 支付完成后主动同步结果(回调兜底)
|
|
||||||
const q = await request.get(`/mini/payment/online/${payment_no}/query`);
|
|
||||||
// q.data.status === 1 → 支付成功,刷新账单列表
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 查询支付结果(主动同步)
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| 请求方式 | `GET` |
|
|
||||||
| 路径 | `/mini/payment/online/{payment_no}/query` |
|
|
||||||
| 鉴权 | 门店 token(仅可查询本店支付单) |
|
|
||||||
|
|
||||||
### 路径参数
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `payment_no` | 下单返回的支付单号(如 `ZF202608270001`) |
|
|
||||||
|
|
||||||
### 行为说明
|
|
||||||
|
|
||||||
- 本地已是「支付成功」→ 直接返回,不再请求网关;
|
|
||||||
- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑);
|
|
||||||
- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次;
|
|
||||||
- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。
|
|
||||||
|
|
||||||
### 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"msg": "支付成功",
|
|
||||||
"data": {
|
|
||||||
"payment_no": "ZF202608270001",
|
|
||||||
"status": 1,
|
|
||||||
"status_name": "支付成功",
|
|
||||||
"paid_at": "2026-08-27 10:00:00",
|
|
||||||
"trade_no": "2021082722001407831438768160"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `status` 取值
|
|
||||||
|
|
||||||
| 值 | 含义 |
|
|
||||||
|---|---|
|
|
||||||
| `0` | 待支付(网关未确认) |
|
|
||||||
| `1` | 支付成功(账单已置已支付) |
|
|
||||||
| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 支付结果后台通知(服务端对接,无需小程序调用)
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| 请求方式 | `POST`(表单) |
|
|
||||||
| 路径 | `/mini/payment/notify` |
|
|
||||||
| 鉴权 | 无(公开路由,以报文 `sign` 验签) |
|
|
||||||
| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify`) |
|
|
||||||
|
|
||||||
### 处理逻辑
|
|
||||||
|
|
||||||
1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value` 以 `&` 拼接 + `&key=SignKey`,MD5 大写);
|
|
||||||
2. 验签失败 → 应答 `{"code":"01"}`;
|
|
||||||
3. 按 `mer_order_id` 定位支付单,校验 `order_status=1` 且 `order_amt` 与支付单金额一致(防篡改);
|
|
||||||
4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店;
|
|
||||||
5. 重复通知直接应答成功,不重复结账。
|
|
||||||
|
|
||||||
### 应答报文(网关约定格式)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" }
|
|
||||||
```
|
|
||||||
|
|
||||||
网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 后台配置(上线前必配)
|
|
||||||
|
|
||||||
配置优先级:后台「系统设置 → 支付配置」> `.env`。
|
|
||||||
|
|
||||||
### 后台支付配置(site_config `pay` 组,已内置配置项)
|
|
||||||
|
|
||||||
| 配置项 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) |
|
|
||||||
| `wangpu_organiz_no` | 合作机构渠道号 organiz_no |
|
|
||||||
| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) |
|
|
||||||
| `wangpu_mer_code` | 商户号 mer_code |
|
|
||||||
| `wangpu_term_code` | 终端号 term_code |
|
|
||||||
| `wangpu_sign_key` | 加签专用 Key(SignKey) |
|
|
||||||
| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid |
|
|
||||||
| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) |
|
|
||||||
|
|
||||||
### .env 备选配置
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
WECHAT_MINI_APPID= # 小程序 AppID(code2session 必需)
|
|
||||||
WECHAT_MINI_SECRET= # 小程序 Secret(code2session 必需)
|
|
||||||
WANGPU_BASE_URL=
|
|
||||||
WANGPU_ORGANIZ_NO=
|
|
||||||
WANGPU_MER_NO=
|
|
||||||
WANGPU_MER_CODE=
|
|
||||||
WANGPU_TERM_CODE=
|
|
||||||
WANGPU_SIGN_KEY=
|
|
||||||
WANGPU_SUB_APPID=
|
|
||||||
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。
|
|
||||||
|
|
||||||
## 与线下凭证支付的关系
|
|
||||||
|
|
||||||
两种付款方式并存,同一张账单同一时刻只能处于一条支付链路:
|
|
||||||
|
|
||||||
| | 在线支付(本档) | 线下凭证支付 |
|
|
||||||
|---|---|---|
|
|
||||||
| 入口 | `POST /mini/payment/online` | `POST /mini/payment` |
|
|
||||||
| 支付单 `pay_type` | `2` | `1` |
|
|
||||||
| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 |
|
|
||||||
| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 |
|
|
||||||
| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 |
|
|
||||||
|
|
||||||
账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# 小程序接口文档:采购运营报表
|
|
||||||
|
|
||||||
> 门店端运营报表:拉取「一周 / 一月 / 任意选定时间段」内的采购总金额,以及每个单品的累计采购金额与金额占比。
|
|
||||||
>
|
|
||||||
> 示例场景:上个月我店一共采购了 10 万元,其中土豆 8764 元(占比 8.8%)、洋葱 3664 元(占比 3.7%)。
|
|
||||||
|
|
||||||
## 接口信息
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| 请求方式 | `GET` |
|
|
||||||
| 路径 | `/mini/report/purchase` |
|
|
||||||
| 鉴权 | 门店 token(`Authorization: Bearer <token>`),与其他小程序接口一致 |
|
|
||||||
| 数据范围 | 仅当前登录门店自身的订货数据 |
|
|
||||||
|
|
||||||
## 请求参数
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `preset` | string | 否 | 周期预设:`week` 本周 / `last_week` 上周 / `month` 本月 / `last_month` 上月。**缺省为 `month`** |
|
|
||||||
| `start_date` | string | 否 | 自定义开始日期,格式 `Y-m-d`(如 `2026-07-01`)。需与 `end_date` 成对出现 |
|
|
||||||
| `end_date` | string | 否 | 自定义结束日期,格式 `Y-m-d`,不能早于 `start_date` |
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 同时传 `start_date` + `end_date` 时为**自定义区间,优先于 `preset`**(响应中 `preset` 返回 `custom`);
|
|
||||||
- 只传其中一个日期、日期格式错误、结束日期早于开始日期、preset 非枚举值,均返回参数错误;
|
|
||||||
- 进行中的周期(本周/本月)结束日期自动**封顶为今天**;周以**周一**为起点。
|
|
||||||
|
|
||||||
## 响应示例
|
|
||||||
|
|
||||||
`GET /mini/report/purchase?preset=last_month`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": {
|
|
||||||
"preset": "last_month",
|
|
||||||
"start_date": "2026-07-01",
|
|
||||||
"end_date": "2026-07-31",
|
|
||||||
"total_amount": "100000.00",
|
|
||||||
"total_quantity": 1700,
|
|
||||||
"order_count": 20,
|
|
||||||
"item_count": 2,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"product_id": 11,
|
|
||||||
"product_name": "土豆",
|
|
||||||
"product_spec": "50斤/袋",
|
|
||||||
"unit": "斤",
|
|
||||||
"quantity": 500,
|
|
||||||
"weight": "0.000",
|
|
||||||
"amount": "8764.00",
|
|
||||||
"percent": 8.8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"product_id": 12,
|
|
||||||
"product_name": "洋葱",
|
|
||||||
"product_spec": "20斤/袋",
|
|
||||||
"unit": "斤",
|
|
||||||
"quantity": 200,
|
|
||||||
"weight": "0.000",
|
|
||||||
"amount": "3664.00",
|
|
||||||
"percent": 3.7
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 响应字段
|
|
||||||
|
|
||||||
### 顶层(`data`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `preset` | string | 实际生效的周期预设(`week`/`last_week`/`month`/`last_month`/`custom`) |
|
|
||||||
| `start_date` | string | 实际统计开始日期 `Y-m-d` |
|
|
||||||
| `end_date` | string | 实际统计结束日期 `Y-m-d` |
|
|
||||||
| `total_amount` | string | 周期内采购总金额(元,2 位小数;金额一律以字符串返回防精度丢失) |
|
|
||||||
| `total_quantity` | number | 周期内订货总量(各单品数量之和) |
|
|
||||||
| `order_count` | number | 周期内有效订货单数 |
|
|
||||||
| `item_count` | number | 单品个数(= `items` 长度) |
|
|
||||||
| `items` | array | 单品累计列表,**按金额降序** |
|
|
||||||
|
|
||||||
### 单品行(`data.items[]`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `product_id` | number | 商品 ID |
|
|
||||||
| `product_name` | string | 品名(下单时快照,商品档案改名/删除不影响历史统计) |
|
|
||||||
| `product_spec` | string | 规格/包规(快照) |
|
|
||||||
| `unit` | string | 计价单位(快照) |
|
|
||||||
| `quantity` | number | 周期内累计订货量 |
|
|
||||||
| `weight` | string | 周期内累计重量(3 位小数,未称重为 `0.000`) |
|
|
||||||
| `amount` | string | 周期内累计采购金额(元,2 位小数) |
|
|
||||||
| `percent` | number | 金额占比(%,1 位小数,四舍五入;如 `8.8` 表示 8.8%。周期内无数据时为 `0`) |
|
|
||||||
|
|
||||||
## 统计口径
|
|
||||||
|
|
||||||
- 数据源为**门店订货单明细**(下单时快照的等级单价 × 数量),即门店视角的应付采购金额;
|
|
||||||
- **已取消**订单与后台**已删除**订单不计入统计,其余状态(待接单/已接单/采购中/配送中/已完成)全部计入;
|
|
||||||
- 单品按 `product_id` 聚合,同一商品在周期内多次下单会累计为一行;
|
|
||||||
- 占比 = 单品金额 ÷ 总金额 × 100,保留 1 位小数(四舍五入),各行占比之和可能因舍入存在 ±0.1 的误差,属正常现象。
|
|
||||||
|
|
||||||
## 错误响应
|
|
||||||
|
|
||||||
与小程序其他接口一致:`success: false` + `msg` 描述原因。
|
|
||||||
|
|
||||||
| 场景 | msg 示例 |
|
|
||||||
|---|---|
|
|
||||||
| 未登录 / token 失效 | `Unauthenticated`(按全局鉴权约定) |
|
|
||||||
| 账号被停用 | `账号不存在或已被停用` |
|
|
||||||
| preset 非法 | `preset 参数只能是 week/last_week/month/last_month` |
|
|
||||||
| 日期格式错误 | `开始日期格式为 Y-m-d` |
|
|
||||||
| 结束早于开始 | `结束日期不能早于开始日期` |
|
|
||||||
| 自定义区间只传一侧 | `自定义区间需同时提供开始与结束日期` |
|
|
||||||
|
|
||||||
## 前端对接建议
|
|
||||||
|
|
||||||
- 顶部放周期切换 Tab(本周 / 上周 / 本月 / 上月 / 自定义),自定义时弹出日期选择器,选中后传 `start_date` + `end_date`;
|
|
||||||
- 头部卡片展示 `total_amount`(总金额)与 `order_count`(单数);
|
|
||||||
- 列表直接渲染 `items`(已按金额降序),每行展示品名、规格、累计金额与 `percent`%;如需饼图可直接用 `items[].percent`;
|
|
||||||
- 区间无数据时 `items` 为空数组、`total_amount` 为 `"0.00"`,前端展示空状态即可。
|
|
||||||
@@ -101,8 +101,8 @@ class SysSiteConfigItemsController extends BaseController
|
|||||||
SysSiteConfigService::refreshSiteConfig();
|
SysSiteConfigService::refreshSiteConfig();
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
$message = '设置保存失败:' . $result['message'];
|
$message = '设置保存失败';
|
||||||
return $this->error($message);
|
return $this->error($result['errors'], $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新设置 */
|
/** 刷新设置 */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-DOLiJ5rL.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./store-JDnJsv44.js";import{t as m}from"./download-DC9wDwqQ.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return m(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+¥`,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[`-¥`,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[m]=l.useForm();(0,h.useEffect)(()=>{p().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>m.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:m,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default};
|
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-DOLiJ5rL.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./download-DC9wDwqQ.js";import{t as m}from"./store-JDnJsv44.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return p(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+¥`,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[`-¥`,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[p]=l.useForm();(0,h.useEffect)(()=>{m().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>p.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:p,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(){return e({url:`/customer/supplier/options`,method:`get`})}async function r(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function i(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function a(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function o(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function s(t){return e({url:`/product/goods/batch`,method:`delete`,data:{ids:t}})}async function c(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function l(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{c as a,n as c,i,l as n,r as o,a as r,o as s,s as t};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function r(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function i(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function a(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function o(t){return e({url:`/product/goods/batch`,method:`delete`,data:{ids:t}})}async function s(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function c(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{s as a,r as i,c as n,n as o,i as r,a as s,o as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
var e={0:{text:`下架`,color:`default`},1:{text:`上架`,color:`success`}};export{e as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
||||||
File diff suppressed because one or more lines are too long
@@ -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,r){return e({url:`/purchase/order/${t}/cell`,method:`get`,params:{product_id:n,store_id:r}})}async function o(t,n){return e({url:`/purchase/order/cell/${t}`,method:`put`,data:n})}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{l as a,a as c,f as d,o as f,h as i,r as l,d as m,p as n,n as o,i as p,m as r,c as s,u as t,s as u};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{t as a}from"./PlusOutlined-B8K2rG8r.js";import{t as o}from"./button-BILozH6U.js";import{r as s}from"./DoubleRightOutlined-Blfli1UU.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./image-CTRBNpBE.js";import{r as d}from"./PictureOutlined-CB8Pr7jP.js";import{t as f}from"./tag-DBV1bHre.js";import{t as p}from"./DeleteOutlined-DwOvobSM.js";import{t as m}from"./XinTable-DOLiJ5rL.js";import{t as h}from"./AuthButton-DvrpreK4.js";import{t as g}from"./iProduct-l9WpLyUD.js";import{o as _}from"./goods-DPA1smJU.js";var v=e(t(),1),y={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}};async function b(e){return r({url:`/client/special/batch`,method:`post`,data:{product_ids:e}})}async function x(e){return r({url:`/client/special/batch`,method:`delete`,data:{ids:e}})}var S=n(),{Title:C,Text:w}=i,T=()=>{let e=(0,v.useRef)(null),[t,n]=(0,v.useState)([]),[r,i]=(0,v.useState)(!1),T=()=>{window.$modal?.confirm({title:`确定要删除选中的 ${t.length} 条推荐吗?`,okText:`删除`,okButtonProps:{danger:!0},cancelText:`取消`,onOk:async()=>{i(!0);try{await x(t),d.success(`批量删除成功`),n([]),await e.current?.reload()}finally{i(!1)}}})},[E,D]=(0,v.useState)(!1),[O,k]=(0,v.useState)(!1),[A]=l.useForm(),[j,M]=(0,v.useState)([]),N=()=>{A.resetFields(),D(!0),j.length===0&&_().then(e=>M(e.data.data??[]))};return(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`mb-5`,children:[(0,S.jsx)(C,{level:3,children:`特价推荐`}),(0,S.jsx)(w,{type:`secondary`,children:`小程序首页「特价推荐」区展示的商品;价格按门店客户等级价展示,推荐停用或商品下架后不展示,排序越小越靠前。`})]}),(0,S.jsx)(m,{api:`/client/special`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`商品图片`,dataIndex:`image`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100,render:(e,t)=>{let n=t.product?.images_arr?.[0]?.preview_url;return n?(0,S.jsx)(u,{src:n,width:60,height:60,style:{objectFit:`cover`,borderRadius:4}}):`-`}},{title:`商品名称`,dataIndex:`keyword`,hideInForm:!0,valueType:`text`,fieldProps:{placeholder:`按商品名称搜索`},render:(e,t)=>{let n=t.product;return n?`${n.name}${n.spec?`(${n.spec})`:``}`:`-`}},{title:`市场`,dataIndex:`market`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.product?.market||`-`},{title:`计价单位`,dataIndex:`unit`,hideInForm:!0,hideInSearch:!0,align:`center`,width:90,render:(e,t)=>t.product?.unit||`-`},{title:`商品状态`,dataIndex:`product_status`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100,render:(e,t)=>{let n=g[t.product?.status??1];return(0,S.jsx)(f,{color:n?.color,children:n?.text})}},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=y[t.status??1];return(0,S.jsx)(f,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.special`,tableRef:e,addShow:!1,rowSelection:{selectedRowKeys:t,onChange:e=>n(e.map(Number))},actionBarRender:e=>[e.search,(0,S.jsx)(h,{auth:`client.special.create`,children:(0,S.jsx)(o,{type:`primary`,icon:(0,S.jsx)(a,{}),onClick:N,children:`添加商品`})},`add`),(0,S.jsx)(h,{auth:`client.special.delete`,children:(0,S.jsxs)(o,{danger:!0,icon:(0,S.jsx)(p,{}),disabled:t.length===0,loading:r,onClick:T,children:[`批量删除`,t.length>0?` (${t.length})`:``]})},`batchDelete`)],formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}}),(0,S.jsx)(c,{title:`添加推荐商品`,open:E,onCancel:()=>D(!1),onOk:()=>A.submit(),confirmLoading:O,okText:`添加`,destroyOnHidden:!0,children:(0,S.jsx)(l,{form:A,layout:`vertical`,onFinish:async t=>{k(!0);try{let{added:n=0,skipped:r=0}=(await b(t.product_ids)).data.data??{};d.success(`已添加 ${n} 件推荐商品${r>0?`,${r} 件已在推荐中自动跳过`:``}`),D(!1),await e.current?.reload()}finally{k(!1)}},preserve:!1,children:(0,S.jsx)(l.Item,{label:`商品`,name:`product_ids`,rules:[{required:!0,message:`请选择商品`}],children:(0,S.jsx)(s,{mode:`multiple`,showSearch:{optionFilterProp:`label`},placeholder:`搜索并选择商品(可多选)`,options:j.map(e=>({value:e.id,label:`${e.name}${e.spec?`(${e.spec})`:``}`}))})})})})]})};export{T as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./request--UCyt0wo.js";async function t(){return e({url:`/customer/supplier/options`,method:`get`})}export{t};
|
||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XinAdmin</title>
|
<title>XinAdmin</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CA-P_7Vi.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/rolldown-runtime-BgaNhQyE.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.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/useMobile-Bcq0nkW4.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
|
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
|
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-9KKK8arf.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bz9U6gHM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
{
|
{
|
||||||
private static int $billSeq = 0;
|
private static int $billSeq = 0;
|
||||||
|
|
||||||
/** 造一张账单(默认配送费 10、筐 2×5 + 托盘 1×20 = 附加 30、总额 140,未支付) */
|
/** 造一张账单(默认配送费 10、筐 2×5 + 托盘 1×20 = 附加 30、售后 0、总额 140,未支付) */
|
||||||
private function makeBill(StoreModel $store, array $attributes = []): BillModel
|
private function makeBill(StoreModel $store, array $attributes = []): BillModel
|
||||||
{
|
{
|
||||||
$seq = ++self::$billSeq;
|
$seq = ++self::$billSeq;
|
||||||
@@ -36,6 +36,7 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
'box_price' => '5.00',
|
'box_price' => '5.00',
|
||||||
'tray_price' => '20.00',
|
'tray_price' => '20.00',
|
||||||
'added_amount' => '30.00',
|
'added_amount' => '30.00',
|
||||||
|
'after_sale' => '0.00',
|
||||||
'total_amount' => '140.00',
|
'total_amount' => '140.00',
|
||||||
'status' => BillModel::STATUS_UNPAID,
|
'status' => BillModel::STATUS_UNPAID,
|
||||||
], $attributes));
|
], $attributes));
|
||||||
@@ -70,7 +71,7 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
$productB = ProductModel::factory()->create(['category_id' => $root->id]);
|
$productB = ProductModel::factory()->create(['category_id' => $root->id]);
|
||||||
|
|
||||||
$bill1 = $this->makeBill($store);
|
$bill1 = $this->makeBill($store);
|
||||||
$bill2 = $this->makeBill($store);
|
$bill2 = $this->makeBill($store, ['after_sale' => '-8.00']);
|
||||||
// 同一商品跨账单:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37
|
// 同一商品跨账单:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37
|
||||||
$this->makeItem($bill1, $productA, 10, '2.00');
|
$this->makeItem($bill1, $productA, 10, '2.00');
|
||||||
$this->makeItem($bill2, $productA, 6, '3.00');
|
$this->makeItem($bill2, $productA, 6, '3.00');
|
||||||
@@ -84,8 +85,8 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
|
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
|
||||||
static function (BillExport $export) use ($productA, $productB): bool {
|
static function (BillExport $export) use ($productA, $productB): bool {
|
||||||
$rows = $export->collection()->values();
|
$rows = $export->collection()->values();
|
||||||
// 标题1 + 范围2 + 空行1 + 列头1 + 明细2 + 合计4 = 11 行
|
// 标题1 + 范围2 + 空行1 + 列头1 + 明细2 + 合计5 = 12 行
|
||||||
if ($rows->count() !== 11) {
|
if ($rows->count() !== 12) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$summary = $rows->slice(7)->values();
|
$summary = $rows->slice(7)->values();
|
||||||
// 合计:数量 20、商品金额 58.00;配送费 20.00;附加 60.00(筐 4 / 托盘 2);总金额 138.00
|
// 合计:数量 20、商品金额 58.00;配送费 20.00;附加 60.00(筐 4 / 托盘 2);售后 -8.00;总金额 130.00
|
||||||
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) {
|
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -114,8 +115,11 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
if (! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个') || (float) $summary[2][7] !== 60.0) {
|
if (! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个') || (float) $summary[2][7] !== 60.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (! str_contains((string) $summary[3][0], '售后金额合计') || (float) $summary[3][7] !== -8.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (float) $summary[3][7] === 138.0;
|
return (float) $summary[4][7] === 130.0;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,15 +160,15 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$summary = $rows->slice(6)->values();
|
$summary = $rows->slice(6)->values();
|
||||||
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 全额;总金额 60.00
|
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00
|
||||||
if ((float) $summary[0][7] !== 20.0) {
|
if ((float) $summary[0][7] !== 20.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0) {
|
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0 || (float) $summary[3][7] !== 0.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) $summary[3][7] === 60.0;
|
return (float) $summary[4][7] === 60.0;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,7 +246,7 @@ class BillExportTest extends ProcurementTestCase
|
|||||||
static function (BillExport $export): bool {
|
static function (BillExport $export): bool {
|
||||||
$rows = $export->collection()->values();
|
$rows = $export->collection()->values();
|
||||||
// 明细仅本店账单的 1 行(他店账单未混入)
|
// 明细仅本店账单的 1 行(他店账单未混入)
|
||||||
return $rows->count() === 10;
|
return $rows->count() === 11;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use App\Models\PurchaseOrderModel;
|
|||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use Modules\SystemTool\Models\SysFileModel;
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||||
|
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||||
|
use Modules\SystemTool\Services\SysSiteConfigService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单链路与支付:订单状态随业务链自动推进(采购单完成→配送中、生成账单→已完成);
|
* 账单链路与支付:订单状态随业务链自动推进(采购单完成→配送中、生成账单→已完成);
|
||||||
@@ -72,7 +75,7 @@ class BillPaymentTest extends ProcurementTestCase
|
|||||||
], $attributes));
|
], $attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成账单:按门店保存售后说明与备注(可选,不参与金额计算) */
|
/** 生成账单:按门店保存售后金额(可正负,计入总金额)与备注(仅存档不参与计算) */
|
||||||
public function test_generate_bill_saves_after_sale_and_remark(): void
|
public function test_generate_bill_saves_after_sale_and_remark(): void
|
||||||
{
|
{
|
||||||
$level = CustomerLevelModel::factory()->create();
|
$level = CustomerLevelModel::factory()->create();
|
||||||
@@ -97,17 +100,17 @@ class BillPaymentTest extends ProcurementTestCase
|
|||||||
'delivery_fee' => 0,
|
'delivery_fee' => 0,
|
||||||
'box_num' => 0,
|
'box_num' => 0,
|
||||||
'tray_num' => 0,
|
'tray_num' => 0,
|
||||||
'after_sale' => '白菜烂叶 2 斤已协商',
|
'after_sale' => -5.5,
|
||||||
'remark' => '下次配送顺带回收筐',
|
'remark' => '白菜烂叶 2 斤已协商',
|
||||||
]],
|
]],
|
||||||
])->assertJsonPath('success', true);
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
$bill = BillModel::where('store_id', $store->id)->first();
|
$bill = BillModel::where('store_id', $store->id)->first();
|
||||||
$this->assertSame('白菜烂叶 2 斤已协商', $bill->after_sale);
|
$this->assertSame('-5.50', (string) $bill->after_sale);
|
||||||
$this->assertSame('下次配送顺带回收筐', $bill->remark);
|
$this->assertSame('白菜烂叶 2 斤已协商', $bill->remark);
|
||||||
$this->assertSame('20.00', (string) $bill->total_amount, '售后/备注不影响金额');
|
$this->assertSame('14.50', (string) $bill->total_amount, '总金额 = 商品 20.00 + 售后金额 -5.50');
|
||||||
|
|
||||||
// 不传售后/备注时默认空字符串
|
// 不传售后金额时按 0 计入,备注默认空字符串
|
||||||
$store2 = StoreModel::factory()->create(['level_id' => $level->id]);
|
$store2 = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
$this->actingAsMiniStore($store2);
|
$this->actingAsMiniStore($store2);
|
||||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||||
@@ -125,8 +128,115 @@ class BillPaymentTest extends ProcurementTestCase
|
|||||||
])->assertJsonPath('success', true);
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
$bill2 = BillModel::where('store_id', $store2->id)->first();
|
$bill2 = BillModel::where('store_id', $store2->id)->first();
|
||||||
$this->assertSame('', (string) $bill2->after_sale);
|
$this->assertSame('0.00', (string) $bill2->after_sale);
|
||||||
$this->assertSame('', (string) $bill2->remark);
|
$this->assertSame('', (string) $bill2->remark);
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
[$store, , $bill] = $this->makeBillViaChain();
|
||||||
|
$this->assertSame('20.00', (string) $bill->total_amount, '基线:无售后金额时总额=商品金额');
|
||||||
|
|
||||||
|
// 重新走一条链:售后金额 +8.00,配送费 2.00 → 总额 20 + 2 + 8 = 30.00
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store2 = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store2);
|
||||||
|
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 4]]])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$order2 = StoreOrderModel::where('store_id', $store2->id)->first();
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->putJson("/order/store/{$order2->id}/status", ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||||
|
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$purchase2 = PurchaseOrderModel::latest('id')->first();
|
||||||
|
$this->putJson("/purchase/order/{$purchase2->id}/finish")->assertJsonPath('success', true);
|
||||||
|
$this->postJson("/purchase/order/{$purchase2->id}/bill", [
|
||||||
|
'stores' => [[
|
||||||
|
'store_id' => $store2->id,
|
||||||
|
'delivery_fee' => 2,
|
||||||
|
'box_num' => 0,
|
||||||
|
'tray_num' => 0,
|
||||||
|
'after_sale' => 8,
|
||||||
|
]],
|
||||||
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$bill2 = BillModel::where('store_id', $store2->id)->first();
|
||||||
|
$this->assertSame('8.00', (string) $bill2->after_sale);
|
||||||
|
$this->assertSame('30.00', (string) $bill2->total_amount, '总金额 = 商品 20.00 + 配送费 2.00 + 售后金额 8.00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成账单:售后金额非数值 → 拒绝 */
|
||||||
|
public function test_generate_bill_rejects_non_numeric_after_sale(): void
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$purchase = PurchaseOrderModel::factory()->create(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||||
|
|
||||||
|
$this->postJson("/purchase/order/{$purchase->id}/bill", [
|
||||||
|
'stores' => [[
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'delivery_fee' => 0,
|
||||||
|
'box_num' => 0,
|
||||||
|
'tray_num' => 0,
|
||||||
|
'after_sale' => '白菜烂叶 2 斤已协商',
|
||||||
|
]],
|
||||||
|
])->assertJsonPath('success', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单状态随业务链自动推进:接单→采购中→配送中→(生成账单)已完成 */
|
/** 订单状态随业务链自动推进:接单→采购中→配送中→(生成账单)已完成 */
|
||||||
@@ -317,4 +427,35 @@ class BillPaymentTest extends ProcurementTestCase
|
|||||||
$this->assertStringContainsString('voucher/a.jpg', $data['payment']['voucher_urls'][0]);
|
$this->assertStringContainsString('voucher/a.jpg', $data['payment']['voucher_urls'][0]);
|
||||||
$this->assertStringContainsString('voucher/b.jpg', $data['payment']['voucher_urls'][1], '凭证顺序保持提交顺序');
|
$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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台特价推荐(客户端配置):批量添加(重复自动跳过)、商品名搜索、
|
||||||
|
* 编辑排序/状态、单个/批量删除、权限点拦截
|
||||||
|
*/
|
||||||
|
class ClientSpecialTest extends ProcurementTestCase
|
||||||
|
{
|
||||||
|
/** 批量添加成功:全部写入推荐表,added/skipped 计数正确 */
|
||||||
|
public function test_batch_add_creates_specials(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$ids = ProductModel::factory()->count(3)->create()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => $ids])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.added', 3)
|
||||||
|
->assertJsonPath('data.skipped', 0);
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$this->assertDatabaseHas('mini_home_special', [
|
||||||
|
'product_id' => $id,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重复添加:已在推荐中的商品自动跳过,不产生重复行 */
|
||||||
|
public function test_batch_add_skips_existing_products(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
[$a, $b] = ProductModel::factory()->count(2)->create()->all();
|
||||||
|
HomeSpecialModel::create(['product_id' => $a->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => [$a->id, $b->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.added', 1)
|
||||||
|
->assertJsonPath('data.skipped', 1);
|
||||||
|
|
||||||
|
$this->assertSame(1, HomeSpecialModel::where('product_id', $a->id)->count());
|
||||||
|
$this->assertSame(2, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** product_ids 为空数组 → 校验失败,不添加任何推荐 */
|
||||||
|
public function test_batch_add_requires_non_empty_product_ids(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
ProductModel::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '请选择要添加的商品');
|
||||||
|
|
||||||
|
$this->assertSame(0, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无 client.special.create 权限点 → 拦截 */
|
||||||
|
public function test_batch_add_forbidden_without_permission(): void
|
||||||
|
{
|
||||||
|
// 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->actingAsSysUser(['client.special.query']);
|
||||||
|
|
||||||
|
$product = ProductModel::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => [$product->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', 'No Permission');
|
||||||
|
|
||||||
|
$this->assertSame(0, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表 keyword 按商品名模糊过滤,行内附带商品信息 */
|
||||||
|
public function test_query_filters_by_product_name_keyword(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$cabbage = ProductModel::factory()->create(['name' => '有机大白菜']);
|
||||||
|
$apple = ProductModel::factory()->create(['name' => '红富士苹果']);
|
||||||
|
HomeSpecialModel::create(['product_id' => $cabbage->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $apple->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$data = $this->getJson('/client/special?keyword=' . urlencode('白菜'))
|
||||||
|
->assertOk()
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$this->assertSame('有机大白菜', $data['data'][0]['product']['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑:仅排序与状态可改 */
|
||||||
|
public function test_update_changes_sort_and_status(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => ProductModel::factory()->create()->id,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->putJson("/client/special/{$special->id}", ['sort' => 5, 'status' => 0])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('mini_home_special', [
|
||||||
|
'id' => $special->id,
|
||||||
|
'sort' => 5,
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个删除 + 批量删除:选中的删除,未选中的保留 */
|
||||||
|
public function test_delete_and_batch_delete(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$products = ProductModel::factory()->count(3)->create();
|
||||||
|
$specials = $products->map(static fn ($p) => HomeSpecialModel::create([
|
||||||
|
'product_id' => $p->id, 'sort' => 0, 'status' => 1,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->deleteJson("/client/special/{$specials[0]->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$this->assertDatabaseMissing('mini_home_special', ['id' => $specials[0]->id]);
|
||||||
|
|
||||||
|
$this->deleteJson('/client/special/batch', ['ids' => [$specials[1]->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('mini_home_special', ['id' => $specials[1]->id]);
|
||||||
|
$this->assertDatabaseHas('mini_home_special', ['id' => $specials[2]->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除 ids 为空 → 校验失败 */
|
||||||
|
public function test_batch_delete_requires_non_empty_ids(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => ProductModel::factory()->create()->id,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deleteJson('/client/special/batch', ['ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '请选择要删除的推荐');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('mini_home_special', ['id' => $special->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
|
|||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
|
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
|
||||||
@@ -142,7 +143,7 @@ class ExportTest extends ProcurementTestCase
|
|||||||
{
|
{
|
||||||
[$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers();
|
[$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();
|
Excel::fake();
|
||||||
$this->actingAsSysUser();
|
$this->actingAsSysUser();
|
||||||
@@ -170,10 +171,10 @@ class ExportTest extends ProcurementTestCase
|
|||||||
|| (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) {
|
|| (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 无订货商品行:数量 0、单价留空、门店列 0
|
// 无订货商品行:数量 0、单价按成本价÷包规显示(7÷10=0.70)、门店列 0
|
||||||
$extraRow = $rows->firstWhere(2, $extra->name);
|
$extraRow = $rows->firstWhere(2, $extra->name);
|
||||||
if ($extraRow === null
|
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) {
|
|| (float) $extraRow[12] !== 0.0) {
|
||||||
return false;
|
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
|
public function test_export_supplier_filter(): void
|
||||||
{
|
{
|
||||||
@@ -307,8 +350,9 @@ class ExportTest extends ProcurementTestCase
|
|||||||
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
|
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A
|
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A
|
||||||
@@ -366,10 +410,10 @@ class ExportTest extends ProcurementTestCase
|
|||||||
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
|
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 每个工作表仅含本市场商品
|
// 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
|
||||||
foreach ($sheets as $sheet) {
|
foreach ($sheets as $sheet) {
|
||||||
$rows = $sheet->collection()->values();
|
$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) {
|
$expected = match (true) {
|
||||||
str_ends_with($sheet->title(), '新发地') => [$vegA->name],
|
str_ends_with($sheet->title(), '新发地') => [$vegA->name],
|
||||||
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
|
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class MiniBillTest extends ProcurementTestCase
|
|||||||
'uploader_id' => 1,
|
'uploader_id' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$bill = $this->makeBill($store);
|
$bill = $this->makeBill($store, ['after_sale' => '-3.00']);
|
||||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||||
$order = StoreOrderModel::factory()->create([
|
$order = StoreOrderModel::factory()->create([
|
||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
@@ -182,6 +182,7 @@ class MiniBillTest extends ProcurementTestCase
|
|||||||
$data = $this->getJson("/mini/bill/{$bill->id}")->assertOk()->json('data');
|
$data = $this->getJson("/mini/bill/{$bill->id}")->assertOk()->json('data');
|
||||||
|
|
||||||
$this->assertSame($bill->bill_no, $data['bill']['bill_no']);
|
$this->assertSame($bill->bill_no, $data['bill']['bill_no']);
|
||||||
|
$this->assertSame('-3.00', $data['bill']['after_sale'], '售后金额输出给门店(计入总金额构成)');
|
||||||
$this->assertSame('待支付', $data['bill']['pay_state_name']);
|
$this->assertSame('待支付', $data['bill']['pay_state_name']);
|
||||||
$this->assertSame('2026-08-10', $data['bill']['settlement_date'], '回款周期 0 天,出账当天应结算');
|
$this->assertSame('2026-08-10', $data['bill']['settlement_date'], '回款周期 0 天,出账当天应结算');
|
||||||
$this->assertSame($bill->purchase->purchase_no, $data['bill']['purchase']['purchase_no']);
|
$this->assertSame($bill->purchase->purchase_no, $data['bill']['purchase']['purchase_no']);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,28 @@ use App\Models\NoticeModel;
|
|||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Services\WangpuPayService;
|
use App\Services\WangpuPayService;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账
|
* 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账
|
||||||
|
*
|
||||||
|
* 通讯协议与官方示例 demo/IndexController.php 一致:
|
||||||
|
* 下单/查询的业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输;
|
||||||
|
* 支付结果后台通知为明文表单报文,仅 MD5 验签(ASCII 升序拼接 + 专用加签Key)。
|
||||||
*
|
*
|
||||||
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
|
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
|
||||||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||||||
*/
|
*/
|
||||||
class MiniOnlinePaymentTest extends ProcurementTestCase
|
class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||||
{
|
{
|
||||||
private const string SIGN_KEY = 'test-wangpu-sign-key';
|
/** 测试专用 RSA 密钥对(仅测试使用,与生产密钥无关;公私钥同源便于加解密回环) */
|
||||||
|
private const string TEST_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB';
|
||||||
|
|
||||||
|
private const string TEST_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
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
@@ -32,21 +42,83 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
'services.wangpu.mer_no' => 'mer001',
|
'services.wangpu.mer_no' => 'mer001',
|
||||||
'services.wangpu.mer_code' => 'code001',
|
'services.wangpu.mer_code' => 'code001',
|
||||||
'services.wangpu.term_code' => 'term001',
|
'services.wangpu.term_code' => 'term001',
|
||||||
'services.wangpu.sign_key' => self::SIGN_KEY,
|
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
|
||||||
|
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
|
||||||
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||||
|
'services.wangpu.sign_key' => 'notify-test-key',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 密钥 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::TEST_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::TEST_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);
|
||||||
|
}
|
||||||
|
|
||||||
/** 模拟微信 code2session + 旺铺统一下单均成功 */
|
/** 模拟微信 code2session + 旺铺统一下单均成功 */
|
||||||
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
|
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
|
||||||
{
|
{
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
|
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
|
||||||
'https://wangpu.test/industrial/payment/order' => Http::response([
|
'https://wangpu.test/industrial/payment/order' => Http::response(
|
||||||
'code' => '0000',
|
['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([
|
||||||
'msg' => '调用成功',
|
'order_id' => 'WP202608270001',
|
||||||
'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid],
|
'tradeNo' => 'T20260827001',
|
||||||
], 200),
|
'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
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +161,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
return $payment;
|
return $payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 构造已加签的支付成功通知报文 */
|
/** 构造带 MD5 签名的支付成功通知表单报文(加签算法与官方文档一致,测试侧独立实现) */
|
||||||
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||||
{
|
{
|
||||||
$params = array_merge([
|
$params = array_merge([
|
||||||
@@ -105,16 +177,37 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
'device_no' => 'dev001',
|
'device_no' => 'dev001',
|
||||||
'order_title' => '账单合并付款',
|
'order_title' => '账单合并付款',
|
||||||
], $overrides);
|
], $overrides);
|
||||||
$params['sign'] = app(WangpuPayService::class)->sign($params);
|
|
||||||
|
$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;
|
return $params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旺铺加签算法:按接口文档示例 golden test(MD5 升序拼接 + key) */
|
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
|
||||||
public function test_sign_matches_document_example(): void
|
public function test_aes_encryption_matches_demo_golden(): void
|
||||||
|
{
|
||||||
|
$key = 'AbCdEfGh12345678'; // 16 字节密钥
|
||||||
|
$data = ['mer_order_id' => 'ZF202608270001', 'order_amt' => '0.01', 'organiz_no' => '100005'];
|
||||||
|
|
||||||
|
$service = app(WangpuPayService::class);
|
||||||
|
$method = new ReflectionMethod($service, 'aesEncrypt');
|
||||||
|
$cipher = $method->invoke($service, $data, $key);
|
||||||
|
|
||||||
|
// demo 算法直算的固定密文(算法/填充/编码任何偏差都会改变该值)
|
||||||
|
$this->assertSame(
|
||||||
|
'jl7iWrSNXWQ4D5CCOCENDYG/3JBxiTYWB2XJSMCFGZjfDr5ned8ZLvfXMC+05bVvbBu5aYc6/245g+wuHU0Mo7zUvmMTrPOvgEfHzsnxHa0=',
|
||||||
|
$cipher
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知 MD5 验签 golden test:官方文档示例报文 + SignKey 直算签名一致(含中文、空值剔除) */
|
||||||
|
public function test_notify_sign_matches_doc_golden(): void
|
||||||
{
|
{
|
||||||
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
|
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
|
||||||
|
|
||||||
$sign = app(WangpuPayService::class)->sign([
|
$params = [
|
||||||
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
|
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
|
||||||
'trade_no' => '11420200410120144102483',
|
'trade_no' => '11420200410120144102483',
|
||||||
'mer_no' => '2001071119360E5Riu',
|
'mer_no' => '2001071119360E5Riu',
|
||||||
@@ -128,12 +221,36 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
'order_time' => '2020-04-10 12:01:44',
|
'order_time' => '2020-04-10 12:01:44',
|
||||||
'trade_time' => '2020-04-10 12:01:47',
|
'trade_time' => '2020-04-10 12:01:47',
|
||||||
'gateway_mer_order_id' => '2020041012014445269',
|
'gateway_mer_order_id' => '2020041012014445269',
|
||||||
]);
|
'fee' => '', // 空值不参与签名(平台不下发空值数据元)
|
||||||
|
'sign' => 'A31998F2E0549E0A80B2A4B3A0473784', // 文档示例签名值
|
||||||
|
];
|
||||||
|
|
||||||
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
|
$verified = app(WangpuPayService::class)->verifyNotify($params);
|
||||||
|
|
||||||
|
$this->assertSame($params, $verified);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
|
/** 通知验签失败(签名缺失/错误)抛异常 */
|
||||||
|
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 警告) */
|
||||||
|
public function test_swapped_key_config_fails_with_clear_message(): void
|
||||||
|
{
|
||||||
|
config(['services.wangpu.public_key' => self::TEST_PRIVATE_KEY]); // 公钥栏误填私钥
|
||||||
|
|
||||||
|
$this->expectException(RepositoryException::class);
|
||||||
|
$this->expectExceptionMessage('旺铺平台公钥配置错误(当前内容是私钥,请检查是否填反)');
|
||||||
|
|
||||||
|
app(WangpuPayService::class)->createOrder(['mer_order_id' => 'ZF202609010001', 'order_amt' => '0.01']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起在线支付:锁定账单、创建支付单、上送网关报文正确、openid 绑定到门店 */
|
||||||
public function test_create_online_payment_success(): void
|
public function test_create_online_payment_success(): void
|
||||||
{
|
{
|
||||||
$this->fakeGatewaySuccess();
|
$this->fakeGatewaySuccess();
|
||||||
@@ -149,7 +266,9 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
|
|
||||||
$paymentNo = $response->json('data.payment_no');
|
$paymentNo = $response->json('data.payment_no');
|
||||||
$this->assertSame('150.50', $response->json('data.amount'));
|
$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();
|
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
|
||||||
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
||||||
@@ -163,16 +282,30 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
||||||
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
||||||
|
|
||||||
// 上送网关的报文:商户订单号=支付单号、金额、openid、带签名
|
// 上送网关的加密信封:解出业务报文校验商户订单号/金额/openid/商户信息
|
||||||
Http::assertSent(static function ($request) use ($paymentNo) {
|
Http::assertSent(function ($request) use ($paymentNo) {
|
||||||
|
if (! str_contains($request->url(), '/industrial/payment/order')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$body = $request->data();
|
$body = $request->data();
|
||||||
return str_contains($request->url(), '/industrial/payment/order')
|
if (($body['organizNo'] ?? '') !== 'org001'
|
||||||
&& ($body['mer_order_id'] ?? '') === $paymentNo
|
|| empty($body['serialNo'])
|
||||||
&& ($body['order_amt'] ?? '') === '150.50'
|
|| ($body['version'] ?? '') !== '1.0'
|
||||||
&& ($body['open_id'] ?? '') === 'oOpenidTest001'
|
|| empty($body['timestamp'])
|
||||||
&& ($body['sub_appid'] ?? '') === 'wx-mini-test'
|
) {
|
||||||
&& ! empty($body['sign'])
|
return false;
|
||||||
&& ! empty($body['notifyurl']);
|
}
|
||||||
|
$plain = $this->decryptRequest($body);
|
||||||
|
return ($plain['mer_order_id'] ?? '') === $paymentNo
|
||||||
|
&& ($plain['order_amt'] ?? '') === '150.50'
|
||||||
|
&& ($plain['open_id'] ?? '') === 'oOpenidTest001'
|
||||||
|
&& ($plain['sub_appid'] ?? '') === 'wx-mini-test'
|
||||||
|
&& ($plain['mer_no'] ?? '') === 'mer001'
|
||||||
|
&& ($plain['mer_code'] ?? '') === 'code001'
|
||||||
|
&& ($plain['term_code'] ?? '') === 'term001'
|
||||||
|
&& ($plain['payway_code'] ?? '') === 'WECHAT_MINI'
|
||||||
|
&& ($plain['organiz_no'] ?? '') === 'org001'
|
||||||
|
&& ! empty($plain['notifyurl']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +376,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
/** 支付成功通知:验签 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||||
public function test_notify_settles_payment(): void
|
public function test_notify_settles_payment(): void
|
||||||
{
|
{
|
||||||
$store = StoreModel::factory()->create();
|
$store = StoreModel::factory()->create();
|
||||||
@@ -286,12 +419,17 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
$bill = $this->makeBill($store, '100.00');
|
$bill = $this->makeBill($store, '100.00');
|
||||||
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
|
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
|
||||||
|
|
||||||
// 验签失败
|
// 签名非法 → 验签失败
|
||||||
$badSign = $this->signedNotifyParams($payment);
|
$badSign = $this->signedNotifyParams($payment);
|
||||||
$badSign['sign'] = 'INVALIDSIGN';
|
$badSign['sign'] = 'INVALIDSIGN';
|
||||||
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
|
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
|
||||||
|
|
||||||
// 金额不一致(防篡改)
|
// 篡改金额后未重签 → 验签失败
|
||||||
|
$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']))
|
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||||
->assertJsonPath('code', '01');
|
->assertJsonPath('code', '01');
|
||||||
|
|
||||||
@@ -331,7 +469,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
|||||||
'order_id' => 'WP202608270002',
|
'order_id' => 'WP202608270002',
|
||||||
'trade_time' => '2026-08-27 11:00:00',
|
'trade_time' => '2026-08-27 11:00:00',
|
||||||
];
|
];
|
||||||
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
|
return Http::response(['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope($data), 200);
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\CartModel;
|
use App\Models\CartModel;
|
||||||
use App\Models\CustomerLevelModel;
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\ProductCategoryModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
|
|
||||||
@@ -105,6 +106,27 @@ class MiniProductTest extends ProcurementTestCase
|
|||||||
$this->assertNull($row['price']);
|
$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与数量,响应附悬浮球汇总 */
|
/** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */
|
||||||
public function test_product_list_appends_cart_quantity_and_summary(): void
|
public function test_product_list_appends_cart_quantity_and_summary(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\CartModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序特价推荐列表:免登录浏览 price=null、登录门店按等级上浮换算价、
|
||||||
|
* 停用推荐/下架商品不展示、按推荐排序升序、购物车数量随行输出
|
||||||
|
*/
|
||||||
|
class MiniSpecialTest extends ProcurementTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 造门店 + 上架商品 + 正常推荐
|
||||||
|
*
|
||||||
|
* @return array{0: StoreModel, 1: ProductModel, 2: CustomerLevelModel, 3: HomeSpecialModel}
|
||||||
|
*/
|
||||||
|
private function makeSpecial(string $price = '5.00', int $sort = 0): array
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create([
|
||||||
|
'status' => ProductModel::STATUS_ON,
|
||||||
|
'cost_price' => $price,
|
||||||
|
]);
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'sort' => $sort,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$store, $product, $level, $special];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 游客:返回推荐商品,price=null,成本价不输出 */
|
||||||
|
public function test_guest_sees_specials_with_null_price(): void
|
||||||
|
{
|
||||||
|
[, $product] = $this->makeSpecial();
|
||||||
|
|
||||||
|
$data = $this->getJson('/mini/special/list')->assertOk()->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$row = $data['data'][0];
|
||||||
|
$this->assertSame($product->name, $row['name']);
|
||||||
|
$this->assertNull($row['price'], '未登录不显示价格');
|
||||||
|
$this->assertArrayNotHasKey('cost_price', $row, '成本价不得输出到小程序端');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录门店:按等级上浮比例换算价(成本价 20 上浮 30% = 26.00) */
|
||||||
|
public function test_store_sees_level_price(): void
|
||||||
|
{
|
||||||
|
[$store, $product, $level] = $this->makeSpecial('20.00');
|
||||||
|
$level->update(['percent' => 30]);
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
|
||||||
|
$row = $this->getJson('/mini/special/list')->assertOk()->json('data.data.0');
|
||||||
|
|
||||||
|
$this->assertSame($product->id, $row['id']);
|
||||||
|
$this->assertSame('26.00', $row['price']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用推荐与下架商品的推荐不展示 */
|
||||||
|
public function test_disabled_special_and_off_shelf_product_excluded(): void
|
||||||
|
{
|
||||||
|
[, $productOn] = $this->makeSpecial();
|
||||||
|
|
||||||
|
$offProduct = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $offProduct->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$disabledProduct = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $disabledProduct->id, 'sort' => 0, 'status' => 0]);
|
||||||
|
|
||||||
|
$data = $this->getJson('/mini/special/list')->assertOk()->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$this->assertSame($productOn->id, $data['data'][0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按推荐排序 sort 升序(越小越靠前) */
|
||||||
|
public function test_specials_sorted_by_sort_asc(): void
|
||||||
|
{
|
||||||
|
[, $productB] = $this->makeSpecial('5.00', 5);
|
||||||
|
[, $productA] = $this->makeSpecial('6.00', 1);
|
||||||
|
|
||||||
|
$rows = $this->getJson('/mini/special/list')->assertOk()->json('data.data');
|
||||||
|
|
||||||
|
$this->assertSame($productA->id, $rows[0]['id'], 'sort=1 应排在 sort=5 前');
|
||||||
|
$this->assertSame($productB->id, $rows[1]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录门店:购物车行 ID 与数量随行输出(供列表直接加减) */
|
||||||
|
public function test_cart_quantity_attached_for_store(): void
|
||||||
|
{
|
||||||
|
[$store, $product] = $this->makeSpecial();
|
||||||
|
CartModel::create([
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'quantity' => '2.00',
|
||||||
|
]);
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
|
||||||
|
$row = $this->getJson('/mini/special/list')->assertOk()->json('data.data.0');
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $row['cart_id']);
|
||||||
|
$this->assertSame('2.00', $row['cart_quantity']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\CustomerLevelModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\PurchaseItemCheckModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
@@ -282,7 +283,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
$this->assertSame('50.00', (string) $order->total_amount);
|
$this->assertSame('50.00', (string) $order->total_amount);
|
||||||
|
|
||||||
$purchase = $purchase->fresh();
|
$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(称重仅参考,不参与金额)');
|
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
$purchase = $purchase->fresh();
|
$purchase = $purchase->fresh();
|
||||||
$this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4');
|
$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('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", [
|
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
|
||||||
@@ -402,6 +403,28 @@ class PurchaseEditTest extends ProcurementTestCase
|
|||||||
->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量');
|
->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
|
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(2, $item->fresh()->quantity, '被拒绝后明细不变');
|
||||||
$this->assertSame('10.00', (string) $item->fresh()->cost_price);
|
$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(), '第二次生成应被拒绝,不产生新采购单');
|
$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\StoreModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use Modules\SystemTool\Models\SysFileModel;
|
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 计算,忽略前端金额');
|
$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
|
public function test_store_level_missing_rejected(): void
|
||||||
{
|
{
|
||||||
@@ -392,6 +429,96 @@ class StoreOrderTest extends ProcurementTestCase
|
|||||||
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $pending1->refresh()->status);
|
$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
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import createAxios from '@/utils/request';
|
||||||
|
|
||||||
|
/** 批量添加特价推荐商品(已在推荐中的自动跳过,返回 added/skipped 计数) */
|
||||||
|
export async function batchAddSpecials(product_ids: number[]) {
|
||||||
|
return createAxios<{ added: number; skipped: number }>({
|
||||||
|
url: '/client/special/batch',
|
||||||
|
method: 'post',
|
||||||
|
data: { product_ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除特价推荐 */
|
||||||
|
export async function batchDeleteSpecials(ids: number[]) {
|
||||||
|
return createAxios({
|
||||||
|
url: '/client/special/batch',
|
||||||
|
method: 'delete',
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ export interface PurchaseCellUpdateParams {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成账单:单个门店的配送费/周转筐/托盘数量与售后/备注 */
|
/** 生成账单:单个门店的配送费/周转筐/托盘数量与售后金额/备注 */
|
||||||
export interface BillGenerateStoreParams {
|
export interface BillGenerateStoreParams {
|
||||||
store_id: number;
|
store_id: number;
|
||||||
delivery_fee: number;
|
delivery_fee: number;
|
||||||
box_num: number;
|
box_num: number;
|
||||||
tray_num: number;
|
tray_num: number;
|
||||||
/** 售后说明(文本,仅记录) */
|
/** 售后金额(可正负,计入总金额;正数=加收,负数=售后减免) */
|
||||||
after_sale?: string;
|
after_sale?: number;
|
||||||
/** 备注 */
|
/** 备注 */
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
@@ -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) {
|
export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) {
|
||||||
return createAxios({
|
return createAxios({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
|
|
||||||
|
/** 小程序首页特价推荐商品 */
|
||||||
|
export default interface IHomeSpecial {
|
||||||
|
id?: number;
|
||||||
|
product_id?: number;
|
||||||
|
product?: IProduct;
|
||||||
|
sort?: number;
|
||||||
|
status?: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOME_SPECIAL_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
|
0: { text: '停用', color: 'error' },
|
||||||
|
1: { text: '正常', color: 'success' },
|
||||||
|
};
|
||||||
@@ -74,6 +74,8 @@ export default interface IPurchaseOrder {
|
|||||||
total_weight?: string;
|
total_weight?: string;
|
||||||
estimate_amount?: string;
|
estimate_amount?: string;
|
||||||
actual_amount?: string;
|
actual_amount?: string;
|
||||||
|
/** 应付商品金额 = Σ 已生成门店账单的商品金额(列表接口附;未生成账单为 null) */
|
||||||
|
bill_product_amount?: string | null;
|
||||||
operator_id?: number;
|
operator_id?: number;
|
||||||
operator?: { id: number; nickname: string };
|
operator?: { id: number; nickname: string };
|
||||||
remark?: string;
|
remark?: string;
|
||||||
@@ -87,6 +89,8 @@ export interface IPurchaseDetail {
|
|||||||
items: IPurchaseDetailRow[];
|
items: IPurchaseDetailRow[];
|
||||||
/** 门店账单(采购单完成后按门店生成) */
|
/** 门店账单(采购单完成后按门店生成) */
|
||||||
bills: IBill[];
|
bills: IBill[];
|
||||||
|
/** 单品「已对账」标记的商品ID列表(入库持久化) */
|
||||||
|
checked_product_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
|
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
|
||||||
@@ -109,7 +113,7 @@ export interface IBill {
|
|||||||
tray_price: string;
|
tray_price: string;
|
||||||
/** 附加金额 = 周转筐×筐单价 + 托盘×托盘单价 */
|
/** 附加金额 = 周转筐×筐单价 + 托盘×托盘单价 */
|
||||||
added_amount: string;
|
added_amount: string;
|
||||||
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
|
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额 */
|
||||||
total_amount: string;
|
total_amount: string;
|
||||||
/** 支付状态:0未支付 1已支付 */
|
/** 支付状态:0未支付 1已支付 */
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -122,7 +126,7 @@ export interface IBill {
|
|||||||
paid_operator_id?: number;
|
paid_operator_id?: number;
|
||||||
order_count?: number;
|
order_count?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
/** 售后说明(生成账单时按门店填写) */
|
/** 售后金额(生成账单时按门店填写,可正负,计入总金额) */
|
||||||
after_sale?: string;
|
after_sale?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
/** 门店账单列表/详情接口附带 */
|
/** 门店账单列表/详情接口附带 */
|
||||||
@@ -143,6 +147,10 @@ export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> =
|
|||||||
export interface IBillPrepareStore {
|
export interface IBillPrepareStore {
|
||||||
store_id: number;
|
store_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
|
/** 客户等级名(无等级为 null) */
|
||||||
|
level_name?: string | null;
|
||||||
|
/** 客户等级上浮比例(售后金额折算用;无等级为 '0') */
|
||||||
|
level_percent?: string;
|
||||||
order_count: number;
|
order_count: number;
|
||||||
/** 商品金额(订单汇总,不可修改) */
|
/** 商品金额(订单汇总,不可修改) */
|
||||||
product_amount: string;
|
product_amount: string;
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Button, Form, Image, message, Modal, Select, Tag, Typography } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import XinTable from '@/components/XinTable';
|
||||||
|
import AuthButton from '@/components/AuthButton';
|
||||||
|
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||||
|
import type IHomeSpecial from '@/domain/iHomeSpecial.ts';
|
||||||
|
import { HOME_SPECIAL_STATUS_MAP } from '@/domain/iHomeSpecial.ts';
|
||||||
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
|
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||||
|
import { getProductOptions } from '@/api/product/goods.ts';
|
||||||
|
import { batchAddSpecials, batchDeleteSpecials } from '@/api/client/special.ts';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐商品配置(小程序首页)
|
||||||
|
*
|
||||||
|
* 仅标记推荐商品,不设特价字段:小程序端按门店客户等级价展示;
|
||||||
|
* 推荐停用或商品下架后不展示,排序越小越靠前。
|
||||||
|
*/
|
||||||
|
const HomeSpecialPage: React.FC = () => {
|
||||||
|
const tableRef = useRef<XinTableInstance<IHomeSpecial> | null>(null);
|
||||||
|
|
||||||
|
// ===== 批量删除 =====
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
|
||||||
|
/** 批量删除(成功后清空勾选并刷新列表) */
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
window.$modal?.confirm({
|
||||||
|
title: `确定要删除选中的 ${selectedRowKeys.length} 条推荐吗?`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
await batchDeleteSpecials(selectedRowKeys);
|
||||||
|
message.success('批量删除成功');
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 批量添加 =====
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
|
const [addForm] = Form.useForm();
|
||||||
|
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
|
||||||
|
|
||||||
|
/** 打开添加弹窗(首次打开时加载上架商品选项) */
|
||||||
|
const openAdd = () => {
|
||||||
|
addForm.resetFields();
|
||||||
|
setAddOpen(true);
|
||||||
|
if (productOptions.length === 0) {
|
||||||
|
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 提交批量添加(重复推荐后端自动跳过) */
|
||||||
|
const handleAddSave = async (values: { product_ids: number[] }) => {
|
||||||
|
setAddSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await batchAddSpecials(values.product_ids);
|
||||||
|
const { added = 0, skipped = 0 } = res.data.data ?? {};
|
||||||
|
message.success(`已添加 ${added} 件推荐商品${skipped > 0 ? `,${skipped} 件已在推荐中自动跳过` : ''}`);
|
||||||
|
setAddOpen(false);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
} finally {
|
||||||
|
setAddSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: XinTableColumn<IHomeSpecial>[] = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
width: 70,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品图片',
|
||||||
|
dataIndex: 'image',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => {
|
||||||
|
const url = record.product?.images_arr?.[0]?.preview_url;
|
||||||
|
if (!url) return '-';
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'keyword',
|
||||||
|
hideInForm: true,
|
||||||
|
valueType: 'text',
|
||||||
|
fieldProps: { placeholder: '按商品名称搜索' },
|
||||||
|
render: (_, record) => {
|
||||||
|
const product = record.product;
|
||||||
|
if (!product) return '-';
|
||||||
|
return `${product.name}${product.spec ? `(${product.spec})` : ''}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '市场',
|
||||||
|
dataIndex: 'market',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, record) => record.product?.market || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '计价单位',
|
||||||
|
dataIndex: 'unit',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => record.product?.unit || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品状态',
|
||||||
|
dataIndex: 'product_status',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => {
|
||||||
|
const item = PRODUCT_STATUS_MAP[record.product?.status ?? 1];
|
||||||
|
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sort',
|
||||||
|
valueType: 'digit',
|
||||||
|
hideInSearch: true,
|
||||||
|
initialValue: 0,
|
||||||
|
fieldProps: { min: 0, precision: 0 },
|
||||||
|
align: 'center',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
valueType: 'radioButton',
|
||||||
|
initialValue: 1,
|
||||||
|
fieldProps: {
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: '正常' },
|
||||||
|
{ value: 0, label: '停用' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, record) => {
|
||||||
|
const item = HOME_SPECIAL_STATUS_MAP[record.status ?? 1];
|
||||||
|
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
|
width: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableProps: XinTableProps<IHomeSpecial> = {
|
||||||
|
api: '/client/special',
|
||||||
|
columns,
|
||||||
|
rowKey: 'id',
|
||||||
|
accessName: 'client.special',
|
||||||
|
tableRef,
|
||||||
|
addShow: false,
|
||||||
|
rowSelection: {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||||
|
},
|
||||||
|
actionBarRender: (dom) => [
|
||||||
|
dom.search,
|
||||||
|
<AuthButton key="add" auth="client.special.create">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||||
|
添加商品
|
||||||
|
</Button>
|
||||||
|
</AuthButton>,
|
||||||
|
<AuthButton key="batchDelete" auth="client.special.delete">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
loading={batchDeleting}
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</AuthButton>,
|
||||||
|
],
|
||||||
|
formProps: {
|
||||||
|
grid: true,
|
||||||
|
colProps: { span: 12 },
|
||||||
|
layout: 'vertical',
|
||||||
|
},
|
||||||
|
modalProps: { width: 640 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5">
|
||||||
|
<Title level={3}>特价推荐</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
小程序首页「特价推荐」区展示的商品;价格按门店客户等级价展示,推荐停用或商品下架后不展示,排序越小越靠前。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<XinTable<IHomeSpecial> {...tableProps} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加推荐商品"
|
||||||
|
open={addOpen}
|
||||||
|
onCancel={() => setAddOpen(false)}
|
||||||
|
onOk={() => addForm.submit()}
|
||||||
|
confirmLoading={addSaving}
|
||||||
|
okText="添加"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={addForm} layout="vertical" onFinish={handleAddSave} preserve={false}>
|
||||||
|
<Form.Item
|
||||||
|
label="商品"
|
||||||
|
name="product_ids"
|
||||||
|
rules={[{ required: true, message: '请选择商品' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
placeholder="搜索并选择商品(可多选)"
|
||||||
|
options={productOptions.map((p) => ({
|
||||||
|
value: p.id!,
|
||||||
|
label: `${p.name}${p.spec ? `(${p.spec})` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeSpecialPage;
|
||||||
+22
-21
@@ -294,13 +294,30 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const columns: XinTableColumn<IProduct>[] = [
|
const columns: XinTableColumn<IProduct>[] = [
|
||||||
|
// {
|
||||||
|
// title: 'ID',
|
||||||
|
// dataIndex: 'id',
|
||||||
|
// hideInForm: true,
|
||||||
|
// hideInSearch: true,
|
||||||
|
// width: 70,
|
||||||
|
// align: 'center',
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: '商品名称',
|
||||||
dataIndex: 'id',
|
|
||||||
hideInForm: true,
|
|
||||||
hideInSearch: true,
|
|
||||||
width: 70,
|
|
||||||
align: 'center',
|
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: '商品图片',
|
title: '商品图片',
|
||||||
@@ -335,22 +352,6 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
colProps: { span: 24 },
|
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: '商品描述',
|
title: '商品描述',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
|
|||||||
+434
-388
File diff suppressed because it is too large
Load Diff
@@ -266,6 +266,18 @@ const BillPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '售后金额',
|
||||||
|
dataIndex: 'after_sale',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Text type={Number(record.after_sale ?? 0) < 0 ? 'success' : undefined}>
|
||||||
|
¥{record.after_sale ?? '0.00'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '账单总金额',
|
title: '账单总金额',
|
||||||
dataIndex: 'total_amount',
|
dataIndex: 'total_amount',
|
||||||
@@ -361,7 +373,7 @@ const BillPage: React.FC = () => {
|
|||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<Title level={3}>门店账单</Title>
|
<Title level={3}>门店账单</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘);
|
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)+ 售后金额(可正负);
|
||||||
勾选多张账单可合并导出为一个 Excel(明细跨账单按商品合并,可按一级分类过滤)。
|
勾选多张账单可合并导出为一个 Excel(明细跨账单按商品合并,可按一级分类过滤)。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,6 +400,12 @@ const BillPage: React.FC = () => {
|
|||||||
(筐 {detail.bill.box_num}×¥{detail.bill.box_price},托盘 {detail.bill.tray_num}×¥{detail.bill.tray_price})
|
(筐 {detail.bill.box_num}×¥{detail.bill.box_price},托盘 {detail.bill.tray_num}×¥{detail.bill.tray_price})
|
||||||
</Text>
|
</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="售后金额">
|
||||||
|
<Text type={Number(detail.bill.after_sale ?? 0) < 0 ? 'success' : undefined}>
|
||||||
|
¥{detail.bill.after_sale ?? '0.00'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" className="ml-2!">(可正负,计入总金额)</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单总金额">
|
<Descriptions.Item label="账单总金额">
|
||||||
<Text strong type="danger">¥{detail.bill.total_amount}</Text>
|
<Text strong type="danger">¥{detail.bill.total_amount}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -403,9 +421,6 @@ const BillPage: React.FC = () => {
|
|||||||
{detail.bill.pay_remark ? (
|
{detail.bill.pay_remark ? (
|
||||||
<Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item>
|
<Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
{detail.bill.after_sale ? (
|
|
||||||
<Descriptions.Item label="售后" span={3}>{detail.bill.after_sale}</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
{detail.bill.remark ? (
|
{detail.bill.remark ? (
|
||||||
<Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item>
|
<Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -453,7 +468,7 @@ const BillPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="py-2 text-gray-500">
|
<div className="py-2 text-gray-500">
|
||||||
应收金额 <Text strong type="danger">¥{payTarget?.total_amount ?? '0.00'}</Text>
|
应收金额 <Text strong type="danger">¥{payTarget?.total_amount ?? '0.00'}</Text>
|
||||||
(商品 ¥{payTarget?.product_amount ?? '0.00'} + 配送费 ¥{payTarget?.delivery_fee ?? '0.00'} + 附加 ¥{payTarget?.added_amount ?? '0.00'});
|
(商品 ¥{payTarget?.product_amount ?? '0.00'} + 配送费 ¥{payTarget?.delivery_fee ?? '0.00'} + 附加 ¥{payTarget?.added_amount ?? '0.00'} + 售后 ¥{payTarget?.after_sale ?? '0.00'});
|
||||||
线下收款完成后登记付款信息,账单支付状态将置为「已支付」。
|
线下收款完成后登记付款信息,账单支付状态将置为「已支付」。
|
||||||
</div>
|
</div>
|
||||||
<Form form={payForm} layout="vertical" onFinish={handlePaySave}>
|
<Form form={payForm} layout="vertical" onFinish={handlePaySave}>
|
||||||
|
|||||||
Reference in New Issue
Block a user