Compare commits

..

22 Commits

Author SHA1 Message Date
xinadmin 3005646179 打包前端 2026-09-07 23:28:54 +08:00
xinadmin edf5e42c38 售后金额上浮 2026-09-07 23:27:56 +08:00
xinadmin 58c733baa2 账单总金额 2026-09-07 23:07:12 +08:00
xinadmin 7de51391e4 批量对账 2026-09-07 22:53:50 +08:00
xinadmin 379aeac2b3 前端打包 2026-09-07 12:25:59 +08:00
xinadmin a636bd31b5 前端打包 2026-09-07 12:21:40 +08:00
xinadmin b784210012 账单样式 2026-09-07 12:21:01 +08:00
xinadmin 51ccaaf11a 账单价格 2026-09-06 23:20:00 +08:00
xinadmin 7a895c143e 对账标记 2026-09-06 22:48:45 +08:00
xinadmin f9eb6f7d32 导出时间 2026-09-06 22:37:46 +08:00
xinadmin f8e81cb830 导出 2026-09-06 22:34:25 +08:00
xinadmin 52f396c12e 截单时间 2026-09-05 19:40:52 +08:00
xinadmin b1bb40a088 排序 2026-09-05 15:17:58 +08:00
xinadmin 12cbed411b 默认重量 2026-09-05 14:37:22 +08:00
xinadmin 364471a086 前端打包 2026-09-05 14:05:16 +08:00
xinadmin 5c7a5829af 导出优化 2026-09-05 14:03:23 +08:00
xinadmin 17c1281073 回调优化 2026-09-04 23:30:13 +08:00
xinadmin 968054b9ff 排序 2026-09-04 23:05:31 +08:00
xinadmin c33247f214 排序 2026-09-04 22:42:50 +08:00
xinadmin 1e25459676 排序 2026-09-04 22:10:28 +08:00
xinadmin cf1d3a6229 微信公众号支付 2026-09-04 21:22:46 +08:00
xinadmin 52e339cdd3 应用设置优化 2026-09-04 20:37:34 +08:00
53 changed files with 1949 additions and 220 deletions
+22 -3
View File
@@ -47,16 +47,35 @@ MAIL_PASSWORD=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME=
# 微信小程序(code2session 换取 openid,在线支付必需)
# 微信小程序(code2session 换取 openid,在线支付必需;也可在后台 系统设置→微信应用配置 维护,后台配置优先
WECHAT_MINI_APPID=
WECHAT_MINI_SECRET=
# 旺铺支付网关(也可在后台 系统设置→支付配置 中维护,后台配置优先)
# 微信公众号(网页授权换 openid,公众号 H5 JSAPI 支付用;后台 系统设置→微信应用配置 优先)
WECHAT_MP_APPID=
WECHAT_MP_SECRET=
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
PAY_ONLINE_CHANNEL=wangpu
# 旺铺支付网关(也可在后台 系统设置→旺铺支付 中维护,后台配置优先)
WANGPU_BASE_URL=
WANGPU_ORGANIZ_NO=
WANGPU_MER_NO=
WANGPU_MER_CODE=
WANGPU_TERM_CODE=
WANGPU_SIGN_KEY=
WANGPU_PUBLIC_KEY=
WANGPU_PRIVATE_KEY=
WANGPU_SUB_APPID=
WANGPU_PAYWAY_CODE=WECHAT_MINI
# 公众号场景支付方式代码(留空回退 WANGPU_PAYWAY_CODE
WANGPU_MP_PAYWAY_CODE=
# 微信官方支付 APIv3(也可在后台 系统设置→微信官方支付 中维护,后台配置优先)
WXPAY_MCH_ID=
WXPAY_APPID=
WXPAY_PRIVATE_KEY=
WXPAY_CERTIFICATE=
WXPAY_SECRET_KEY=
WXPAY_PLATFORM_CERT=
WXPAY_PLATFORM_SERIAL=
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -94,7 +94,7 @@ class BillExport implements FromCollection, WithStyles
->all();
$rows[] = ['账单号:' . $billNoText];
$this->specialRows[++$rowIndex] = 'scope';
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
+1 -1
View File
@@ -92,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
$this->specialRows[++$rowIndex] = 'title';
// 范围行:门店 / 导出时间
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
+26 -10
View File
@@ -20,8 +20,10 @@ use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
*/
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{
@@ -114,10 +116,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
];
}
// 导出范围:系统全部上架商品 本采购单有订货的商品(含已删/下架
// 导出范围:系统全部未删除商品(含下架) 本采购单有订货的商品(含已删)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->where('status', ProductModel::STATUS_ON)
->whereNull('deleted_at')
->orWhereIn('id', array_keys($itemGroups))
->get()
->keyBy('id');
@@ -182,7 +184,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
if ($this->supplierId > 0) {
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
}
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
@@ -221,7 +223,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$item['quantity'],
$item['weight'],
$item['amount'],
], array_values($item['store_quantities']));
], array_map(
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
array_values($item['store_quantities']),
));
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
@@ -230,10 +235,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
}
}
// 合计行(行合计 + 门店列合计)
// 合计行(行合计 + 门店列合计;门店无数据显示空白
$rows[] = array_merge(
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_values($storeTotals),
array_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
@@ -255,7 +260,8 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 10, 20, 12, 12, 12, 8, 10, 12, 10, 12, 12];
// 市场/数量列宽减半
$widths = [6, 10, 20, 12, 6, 12, 8, 10, 12, 5, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
@@ -264,7 +270,12 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$storeCount = count($storeIds);
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(12);
$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);
}
// 全部单元格水平/垂直居中
@@ -273,6 +284,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
->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()
+30 -31
View File
@@ -30,7 +30,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header */
private array $specialRows = [];
/** 列头所在行索引 */
@@ -42,9 +42,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = [];
/** @var array{quantity: int, stores: array<int, int>} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0, 'stores' => []];
/**
* @param PurchaseOrderModel $purchase 采购单
* @param SupplierModel $supplier 供应商
@@ -64,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
/**
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
*/
public function collection(): Collection
{
@@ -77,11 +74,16 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no];
// 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
$rows[] = [
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
'',
'',
trim((string) $this->purchase->remark),
];
$this->specialRows[++$rowIndex] = 'title';
// 空行
// 空行(被标题/备注合并区域覆盖)
$rows[] = [''];
$rowIndex++;
@@ -91,8 +93,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->headerRow = $rowIndex;
// 明细行(0 数量的门店格留空不填色)
$totalQuantity = 0;
$storeTotals = array_fill_keys($storeIds, 0);
foreach ($this->productRows as $productRow) {
$line = [
$productRow['product_name'],
@@ -104,24 +104,14 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
$line[] = $quantity > 0 ? $quantity : '';
$storeQuantities[$storeId] = $quantity;
$storeTotals[$storeId] += $quantity;
}
$rows[] = $line;
$this->rowData[++$rowIndex] = [
'quantity' => (int) $productRow['quantity'],
'store_quantities' => $storeQuantities,
];
$totalQuantity += (int) $productRow['quantity'];
}
// 合计行
$rows[] = array_merge(
['合计', $totalQuantity, ''],
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
$this->summaryTotals = ['quantity' => $totalQuantity, 'stores' => $storeTotals];
return $this->rows = collect($rows);
}
@@ -132,7 +122,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
/**
* 标题/列头/合计加粗;全表居中 + 全边框;
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
@@ -141,14 +131,18 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [24, 10, 12];
// 标题合并前三列占两行(A1:C2),备注合并六列两行(D1:I2)
$sheet->mergeCells('A1:C2');
$sheet->mergeCells('D1:I2');
$widths = [24, 10, 6];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$storeIds = array_map('intval', array_keys($this->stores));
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(12);
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
}
// 全部单元格水平/垂直居中
@@ -157,7 +151,15 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 表格区域(列头 → 合计行)添加所有边框
// 列头行自动换行(门店列窄,门店名换行显示)
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
->getAlignment()
->setWrapText(true);
// 备注合并单元格自动换行(长备注多行显示)
$sheet->getStyle('D1')->getAlignment()->setWrapText(true);
// 表格区域(列头 → 数据末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders()
->getAllBorders()
@@ -170,9 +172,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
}
}
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'B' . $this->lastRow, self::QUANTITY_FILL);
}
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) {
@@ -183,16 +182,13 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
}
}
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
}
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
'header' => ['font' => ['bold' => true]],
default => [],
};
if ($style !== []) {
@@ -200,6 +196,9 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
}
}
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
return $styles;
}
@@ -16,10 +16,10 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
use Throwable;
/**
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5
*
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
* → 小程序 wx.requestPayment 完成支付
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
*/
@@ -33,7 +33,10 @@ class OnlinePaymentController extends BaseMiniController
) {}
/**
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
*
* scene=mini(默认):小程序 wx.login code 换 openidwx.requestPayment 调起;
* scene=mp:公众号 H5 网页授权 code 换 openidWeixinJSBridge getBrandWCPayRequest 调起。
* @throws Throwable
*/
#[PostRoute('/payment/online', authorize: true)]
@@ -43,11 +46,13 @@ class OnlinePaymentController extends BaseMiniController
'bill_ids' => 'required|array|min:1',
'bill_ids.*' => 'integer|distinct',
'code' => 'required|string|max:64',
'scene' => 'nullable|string|in:mini,mp',
'remark' => 'nullable|string|max:255',
], [
'bill_ids.required' => '请选择要付款的账单',
'bill_ids.min' => '请选择要付款的账单',
'code.required' => '微信登录凭证缺失,请重新进入小程序',
'code.required' => '微信登录/授权凭证缺失,请重新进入',
'scene.in' => '支付场景不正确',
'remark.max' => '备注超过最大长度',
]);
@@ -58,6 +63,7 @@ class OnlinePaymentController extends BaseMiniController
array_map('intval', $data['bill_ids']),
(string) $data['code'],
(string) ($data['remark'] ?? ''),
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
);
return $this->success([
@@ -99,20 +105,20 @@ class OnlinePaymentController extends BaseMiniController
}
/**
* 旺铺支付结果后台通知(公开路由,解密信封后幂等结账)
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
*
* 通知报文与网关应答同为加密信封(data + signature,商户私钥可解)
* 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/notify', authorize: false)]
public function notify(Request $request): JsonResponse
{
try {
$params = $this->wangpu->decryptNotify($request->all());
$params = $this->wangpu->verifyNotify($request->all());
} catch (Throwable $e) {
Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文解密失败');
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
return $this->notifyAck('01', '报文验签失败');
}
try {
+51 -2
View File
@@ -12,6 +12,7 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillNumberService;
use App\Services\ItemImageResolver;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -33,6 +34,10 @@ class OrderController extends BaseMiniController
public function store(MiniOrderRequest $request): JsonResponse
{
$store = $this->currentStore($request);
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
$this->assertWithinOrderTimeWindow();
$level = $store->level_id > 0 ? $store->level : null;
if ($level === null) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
@@ -72,6 +77,7 @@ class OrderController extends BaseMiniController
$totalQuantity = '0';
$totalAmount = '0';
$totalWeight = '0';
$now = now();
$rows = [];
foreach ($items as $row) {
@@ -85,8 +91,11 @@ class OrderController extends BaseMiniController
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2);
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2);
$totalWeight = bcadd($totalWeight, $weight, 3);
$rows[] = [
'store_id' => $store->id,
@@ -102,7 +111,7 @@ class OrderController extends BaseMiniController
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => 0,
'weight' => $weight,
'amount' => $amount,
'cost_price' => (string) $product->cost_price,
'remark' => '',
@@ -116,7 +125,7 @@ class OrderController extends BaseMiniController
'store_id' => $store->id,
'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_weight' => $totalWeight,
'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark,
@@ -316,4 +325,44 @@ class OrderController extends BaseMiniController
return $this->success([], '订单已取消');
}
/**
* 截单时间校验:业务配置 services.order_time_start / order_time_endHH:mm
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
*/
private function assertWithinOrderTimeWindow(): void
{
$parse = static function (mixed $value): ?int {
$value = trim((string) $value);
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
return null;
}
return (int) $matches[1] * 60 + (int) $matches[2];
};
$startText = trim((string) site_config('services.order_time_start', ''));
$endText = trim((string) site_config('services.order_time_end', ''));
$start = $parse($startText);
$end = $parse($endText);
if ($start === null && $end === null) {
return;
}
if ($start !== null && $start === $end) {
return;
}
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
$allowed = match (true) {
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
$start !== null && $end !== null => $now >= $start && $now <= $end,
$start !== null => $now >= $start,
default => $now <= $end,
};
if (! $allowed) {
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
}
}
}
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PaymentModel;
use App\Services\BillNumberService;
use App\Services\WechatMpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -37,10 +38,19 @@ class PaymentController extends BaseMiniController
return $value;
};
// 支付方式开关(Checkbox 配置自动转数组;未配置时默认全部启用)
$payMethods = site_config('pay.pay_methods');
if (! is_array($payMethods)) {
$payMethods = ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online'];
}
return $this->success([
'pay_methods' => array_values($payMethods),
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
'bank_info' => (string) site_config('pay.bank_info', ''),
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp
'mp_appid' => app(WechatMpService::class)->appid(),
]);
}
@@ -47,8 +47,20 @@ class ProductController extends BaseMiniController
});
}
// 先按分类树展示顺序(与小程序分类栏一致;不在启用分类树中的商品排最后),再按商品排序
$categoryIds = ProductCategoryModel::getTreeOrderedIds(true);
if ($categoryIds !== []) {
$cases = [];
foreach ($categoryIds as $position => $categoryId) {
$cases[] = "WHEN {$categoryId} THEN {$position}";
}
$query->orderByRaw(
'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
);
}
$pageSize = (int) $request->input('pageSize', 10);
$paginator = $query->orderBy('sort', 'desc')
$paginator = $query->orderBy('sort')
->orderBy('id')
->paginate($pageSize);
@@ -51,7 +51,7 @@ class ProductController extends BaseController
$params,
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
)
->orderBy('sort', 'desc')
->orderBy('sort')
->orderBy('id', 'desc')
->paginate($pageSize);
$data->getCollection()->makeVisible('cost_price');
@@ -13,6 +13,7 @@ use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
use App\Models\BillModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -22,6 +23,7 @@ use App\Services\BillGenerateService;
use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService;
use App\Services\PurchaseItemService;
use App\Services\WeightEstimator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -53,7 +55,10 @@ class PurchaseOrderController extends BaseController
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
$data = $this->buildSearch($params, PurchaseOrderModel::query()
->with('operator:id,nickname')
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
->withSum('bills as bill_product_amount', 'product_amount'))
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
@@ -172,6 +177,12 @@ class PurchaseOrderController extends BaseController
return $row;
}, $rows),
'bills' => $bills,
// 单品「已对账」标记(入库持久化,商品ID列表)
'checked_product_ids' => PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->pluck('product_id')
->map(static fn ($v) => (int) $v)
->all(),
]);
}
@@ -481,7 +492,10 @@ class PurchaseOrderController extends BaseController
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => bcadd((string) ($validated['weight'] ?? 0), '0', 3),
// 未传称重时按订货量 × 规格预填参考重量
'weight' => isset($validated['weight'])
? bcadd((string) $validated['weight'], '0', 3)
: WeightEstimator::estimate((string) $product->spec, (string) $product->unit, (string) $quantity),
'amount' => bcmul($price, (string) $quantity, 2),
'cost_price' => (string) $product->cost_price,
'remark' => '',
@@ -609,8 +623,9 @@ class PurchaseOrderController extends BaseController
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name'])
->get(['id', 'name', 'level_id'])
->keyBy('id');
$bills = BillModel::query()
@@ -628,9 +643,13 @@ class PurchaseOrderController extends BaseController
'0'
);
$bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
'store_name' => $store->name ?? ('门店#' . $storeId),
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
'level_name' => $store?->level?->name,
'level_percent' => (string) ($store?->level?->percent ?? '0'),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
@@ -791,6 +810,103 @@ class PurchaseOrderController extends BaseController
});
}
/**
* 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选)
*/
#[PutRoute(route: '/{id}/check/{productId}', authorize: 'query', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
public function toggleItemCheck(int $id, int $productId, Request $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$hasItem = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->exists();
if (! $hasItem) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
$marked = PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->first();
if ($marked !== null) {
$marked->delete();
return $this->success(['checked' => false], '已取消对账标记');
}
PurchaseItemCheckModel::create([
'purchase_id' => $purchase->id,
'product_id' => $productId,
'operator_id' => (int) $request->user()->id,
]);
return $this->success(['checked' => true], '已标记为已对账');
}
/**
* 批量设置单品「已对账」标记(全选用:checked=true 批量标记 / false 批量取消;
* 仅处理本采购单内有订货明细的商品,重复标记幂等;与采购单状态无关)
*/
#[PutRoute(route: '/{id}/check', authorize: 'query', where: ['id' => '[0-9]+'])]
public function batchItemCheck(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'product_ids' => 'required|array|min:1',
'product_ids.*' => 'integer',
'checked' => 'required|boolean',
], [
'product_ids.required' => '请选择商品',
'product_ids.min' => '请选择商品',
]);
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
// 仅处理本采购单内有订货明细的商品(无效商品静默忽略)
$productIds = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', array_map('intval', $data['product_ids']))
->distinct()
->pluck('product_id')
->map(static fn ($v) => (int) $v);
if (! (bool) $data['checked']) {
PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', $productIds)
->delete();
return $this->success(['count' => $productIds->count()], '已取消对账标记');
}
// 批量标记:跳过已标记行,仅补插缺失行(幂等)
$marked = PurchaseItemCheckModel::query()
->where('purchase_id', $purchase->id)
->whereIn('product_id', $productIds)
->pluck('product_id')
->map(static fn ($v) => (int) $v);
$operatorId = (int) $request->user()->id;
$now = now();
$rows = $productIds->diff($marked)
->map(static fn (int $productId) => [
'purchase_id' => $purchase->id,
'product_id' => $productId,
'operator_id' => $operatorId,
'created_at' => $now,
'updated_at' => $now,
])
->values()
->all();
if ($rows !== []) {
PurchaseItemCheckModel::insert($rows);
}
return $this->success(['count' => $productIds->count()], '已标记为已对账');
}
/**
* 采购单编辑闸:仅进行中(待采购)允许修改明细
*/
+30
View File
@@ -74,6 +74,36 @@ class ProductCategoryModel extends Model
return static::buildTree($query->get($columns)->toArray());
}
/**
* 按树展示顺序(sort/id 深度优先)获取分类 ID 列表,供商品列表等按分类顺序排序
*
* @param bool $onlyEnabled 是否仅包含启用分类
* @return array<int, int>
*/
public static function getTreeOrderedIds(bool $onlyEnabled = false): array
{
return static::flattenTreeIds(static::getTreeData(['id', 'parent_id'], $onlyEnabled));
}
/**
* 深度优先展开分类树为有序 ID 列表
*
* @param array<int, array<string, mixed>> $tree
* @return array<int, int>
*/
private static function flattenTreeIds(array $tree): array
{
$ids = [];
foreach ($tree as $node) {
$ids[] = (int) $node['id'];
if (!empty($node['children'])) {
$ids = [...$ids, ...static::flattenTreeIds($node['children'])];
}
}
return $ids;
}
/**
* 将扁平分类列表组装为树
*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
*/
class PurchaseItemCheckModel extends Model
{
protected $table = 'purchase_item_check';
protected $primaryKey = 'id';
protected $fillable = [
'purchase_id',
'product_id',
'operator_id',
];
protected $casts = [
'purchase_id' => 'integer',
'product_id' => 'integer',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 标记人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
}
+10
View File
@@ -44,6 +44,8 @@ class PurchaseOrderModel extends Model
'actual_amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
// 列表 withSum 聚合属性(应付商品金额;无账单时保持 null)
'bill_product_amount' => 'decimal:2',
];
/**
@@ -69,4 +71,12 @@ class PurchaseOrderModel extends Model
{
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
}
/**
* 本采购单生成的门店账单
*/
public function bills(): HasMany
{
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
}
}
+1
View File
@@ -38,6 +38,7 @@ class StoreModel extends Authenticatable
'address',
'payment_cycle_days',
'openid',
'mp_openid',
'status',
'remark',
];
+2
View File
@@ -85,6 +85,8 @@ class BillDetailService
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'spec' => $product->spec,
'price_unit' => $product->price_unit,
'image_ids' => (array) $first->image_ids,
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
+16 -2
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -20,7 +21,8 @@ use Throwable;
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 售后金额 = 按门店填写的调整金额(可正负:正数=加收,负数=售后减免)
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免)
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/
@@ -82,6 +84,14 @@ readonly class BillGenerateService
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
$levelPercents = StoreModel::withTrashed()
->with('level:id,percent')
->whereIn('id', $ordersByStore->keys())
->get(['id', 'level_id'])
->mapWithKeys(static fn (StoreModel $store) => [
$store->id => (string) ($store->level?->percent ?? '0'),
]);
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
@@ -96,7 +106,11 @@ readonly class BillGenerateService
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2
);
$afterSale = bcadd((string) ($row['after_sale'] ?? '0'), '0', 2);
// 售后金额按客户等级上浮比例折算(输入 15、上浮 1% → 实收 15.15;负数同比例放大)
$afterSale = CustomerLevelModel::calcLevelPrice(
(string) ($row['after_sale'] ?? '0'),
$levelPercents[(int) $storeId] ?? '0',
);
$totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
$bill = BillModel::create([
+39 -14
View File
@@ -32,11 +32,17 @@ class OnlinePaymentService
/** 在线支付渠道:微信官方支付 */
public const string CHANNEL_WECHAT = 'wechat';
/** 支付场景:微信小程序(wx.login 换 openidwx.requestPayment 调起) */
public const string SCENE_MINI = 'mini';
/** 支付场景:公众号 H5(网页授权换 openidWeixinJSBridge 调起) */
public const string SCENE_MP = 'mp';
public function __construct(
protected BillNumberService $billNumber,
protected WangpuPayService $wangpu,
protected WechatPayService $wxpay,
protected WechatMiniService $wechat,
protected WechatMpService $wechatMp,
) {}
/**
@@ -55,20 +61,29 @@ class OnlinePaymentService
* 发起在线支付
*
* @param array<int, int> $billIds 合并付款的账单ID
* @param string $code 小程序 wx.login() 返回的登录凭证
* @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分)
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
* @throws Throwable
*/
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
{
$channel = $this->channel();
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
// 换取付款人 openid 并绑定到门店(下次可直接复用)
$openid = $this->wechat->code2session($code);
if ((string) $store->openid !== $openid) {
$store->openid = $openid;
$store->save();
// 换取付款人 openid 并绑定到门店(小程序/公众号分场景绑定,两者 openid 维度不同不可混用)
if ($scene === self::SCENE_MP) {
$openid = $this->wechatMp->code2openid($code);
if ((string) $store->mp_openid !== $openid) {
$store->mp_openid = $openid;
$store->save();
}
} else {
$openid = $this->wechat->code2session($code);
if ((string) $store->openid !== $openid) {
$store->openid = $openid;
$store->save();
}
}
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
@@ -122,12 +137,14 @@ class OnlinePaymentService
(string) $payment->amount,
$openid,
'账单合并付款-' . $payment->payment_no,
$scene === self::SCENE_MP ? $this->wechatMp->appid() : '',
)
: $this->wangpu->createOrder([
'mer_order_id' => $payment->payment_no,
'order_amt' => (string) $payment->amount,
'open_id' => $openid,
'sub_appid' => $this->subAppid(),
'sub_appid' => $this->subAppid($scene),
'payway_code' => $this->wangpu->paywayCode($scene),
'order_title' => '账单合并付款-' . $payment->payment_no,
'notifyurl' => $this->wangpu->notifyUrl(),
]);
@@ -143,7 +160,12 @@ class OnlinePaymentService
$payment->pay_params = $gatewayData;
$payment->save();
return [$payment, $gatewayData];
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
$wxjsapiData = $channel === self::CHANNEL_WANGPU
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
: $gatewayData;
return [$payment, $wxjsapiData];
}
/**
@@ -391,16 +413,19 @@ class OnlinePaymentService
}
/**
* 下单微信子 appid(默认取小程序 appid
* 下单微信子 appid(默认取小程序 appid;公众号 H5 场景取公众号 appid
*/
protected function subAppid(): string
protected function subAppid(string $scene = self::SCENE_MINI): string
{
$subAppid = (string) site_config('pay.wangpu_sub_appid', '');
if ($scene === self::SCENE_MP) {
return $this->wechatMp->appid();
}
$subAppid = (string) site_config('wangpu.sub_appid', '');
if ($subAppid === '') {
$subAppid = (string) config('services.wangpu.sub_appid', '');
}
if ($subAppid === '') {
$subAppid = (string) config('services.wechat.mini.appid', '');
$subAppid = $this->wechat->appid();
}
return trim($subAppid);
}
@@ -410,7 +435,7 @@ class OnlinePaymentService
*/
protected function wxpayMchId(): string
{
$mchId = (string) site_config('pay.wxpay_mch_id', '');
$mchId = (string) site_config('wxpay.mch_id', '');
if ($mchId === '') {
$mchId = (string) config('services.wxpay.mch_id', '');
}
+6 -1
View File
@@ -63,13 +63,18 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
'0'
);
// 参考总重量 = Σ 明细参考重量(下单时按订货量 × 规格预填)
$totalWeight = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->weight, 3),
'0'
);
$purchase = PurchaseOrderModel::create([
'purchase_no' => $this->billNumberService->make('PO'),
'purchase_date' => $date,
'status' => PurchaseOrderModel::STATUS_PENDING,
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_weight' => $totalWeight,
'estimate_amount' => $estimateAmount,
'actual_amount' => 0,
'operator_id' => $operatorId,
+53 -12
View File
@@ -14,9 +14,13 @@ use Illuminate\Support\Str;
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature
* - 整体以 JSON 信封 POST{serialNo, version, timestamp, data, signature, extras, organizNo}
* - 应答/通知反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
*
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.phpenv
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key
* MD5utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
*
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.phpenv
*/
class WangpuPayService
{
@@ -68,26 +72,63 @@ class WangpuPayService
}
/**
* 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知)
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
*
* @param array<string, mixed> $envelope 通知信封(含 data/signature
* @return array<string, mixed> 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 报文解密失败
* @param array<string, mixed> $params 通知表单报文(含 sign
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
*/
public function decryptNotify(array $envelope): array
public function verifyNotify(array $params): array
{
Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]);
$params = $this->decryptEnvelope($envelope);
Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]);
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
throw new RepositoryException('通知报文验签失败');
}
return $params;
}
/**
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
*
* @param array<string, mixed> $params 通知报文
* @throws RepositoryException 加签Key未配置
*/
protected function sign(array $params): string
{
unset($params['sign']);
$params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
ksort($params, SORT_STRING);
$str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params));
$signKey = $this->config('sign_key');
if ($signKey === '') {
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
}
return strtoupper(md5($str . '&key=' . $signKey));
}
/**
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify';
}
/**
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code
*/
public function paywayCode(string $scene = 'mini'): string
{
if ($scene === 'mp') {
$mpPayway = $this->config('mp_payway_code');
if ($mpPayway !== '') {
return $mpPayway;
}
}
return $this->config('payway_code');
}
/**
@@ -279,7 +320,7 @@ class WangpuPayService
*/
protected function config(string $key): string
{
$value = site_config('pay.wangpu_' . $key, '');
$value = site_config('wangpu.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wangpu.' . $key, '');
}
+26 -2
View File
@@ -22,8 +22,8 @@ class WechatMiniService
*/
public function code2session(string $code): string
{
$appid = trim((string) config('services.wechat.mini.appid'));
$secret = trim((string) config('services.wechat.mini.secret'));
$appid = $this->appid();
$secret = $this->config('mini_secret');
if ($appid === '' || $secret === '') {
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
}
@@ -45,4 +45,28 @@ class WechatMiniService
return $openid;
}
/**
* 小程序 AppID(在线支付下单 appid 兜底等场景复用)
*/
public function appid(): string
{
return $this->config('mini_appid');
}
/**
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wechat.' . $key, '');
if ($value === null || $value === '') {
$value = match ($key) {
'mini_appid' => config('services.wechat.mini.appid', ''),
'mini_secret' => config('services.wechat.mini.secret', ''),
default => '',
};
}
return trim((string) $value);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 微信公众号服务(网页授权 code 换 openid,公众号 JSAPI 支付前置)
*
* H5 页面在微信内置浏览器打开 → 网页授权(snsapi_base)回调带回 code
* → 本服务以 code 换付款人 openid(公众号维度,与小程序 openid 不同,不可混用)
*/
class WechatMpService
{
/**
* 网页授权 code 换 openidsns/oauth2/access_token
*
* @return string 用户公众号维度 openid
* @throws RepositoryException 未配置公众号或授权失败
*/
public function code2openid(string $code): string
{
$appid = $this->appid();
$secret = $this->config('mp_secret');
if ($appid === '' || $secret === '') {
throw new RepositoryException('微信公众号未配置 AppID/Secret,请联系管理员');
}
$result = Http::timeout(10)->withOptions([
'verify' => false, // 禁用 SSL 证书验证
])->get('https://api.weixin.qq.com/sns/oauth2/access_token', [
'appid' => $appid,
'secret' => $secret,
'code' => $code,
'grant_type' => 'authorization_code',
])->json();
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
if ($openid === '') {
Log::warning('微信网页授权失败', ['response' => $result]);
throw new RepositoryException('微信网页授权失败:' . ($result['errmsg'] ?? '请重新授权'));
}
return $openid;
}
/**
* 公众号 AppID(公众号 JSAPI 下单 appid、H5 拼网页授权链接使用)
*/
public function appid(): string
{
return $this->config('mp_appid');
}
/**
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wechat.' . $key, '');
if ($value === null || $value === '') {
$value = match ($key) {
'mp_appid' => config('services.wechat.mp.appid', ''),
'mp_secret' => config('services.wechat.mp.secret', ''),
default => '',
};
}
return trim((string) $value);
}
}
+16 -9
View File
@@ -15,7 +15,7 @@ use Throwable;
* - 交易查询:GET /v3/pay/transactions/out-trade-no/{商户订单号}
* - 支付通知:Wechatpay-Signature 验签(配置平台证书/公钥时)+ APIv3 密钥 AES-256-GCM 解密报文
*
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wxpay_*) > config/services.phpenv
* 配置优先级:后台「系统设置 → 微信官方支付」(site_config wxpay.*) > config/services.phpenv
*/
class WechatPayService
{
@@ -29,19 +29,22 @@ class WechatPayService
protected ?Application $application = null;
/**
* 统一下单(小程序 JSAPI
* 统一下单(JSAPI:小程序 / 公众号 H5 通用
*
* @param string $paymentNo 商户订单号(本系统支付单号)
* @param string $amountYuan 金额(元,两位小数)
* @param string $openid 付款人 openid
* @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid
* @param string $description 订单标题
* @return array<string, mixed> wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign
* @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): array
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' => $this->appid(),
'appid' => $appid,
'mchid' => $this->config('mch_id'),
'description' => mb_substr($description, 0, 127),
'out_trade_no' => $paymentNo,
@@ -59,8 +62,9 @@ class WechatPayService
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
}
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid());
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]);
// 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;
}
@@ -212,6 +216,9 @@ class WechatPayService
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', '');
}
@@ -246,7 +253,7 @@ class WechatPayService
*/
protected function config(string $key): string
{
$value = site_config('pay.wxpay_' . $key, '');
$value = site_config('wxpay.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wxpay.' . $key, '');
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Services;
/**
* 参考重量估算:订货量 × 单品规格折算(斤),仅作参考,实际称重以人工录入为准
*/
class WeightEstimator
{
/**
* 估算参考重量(斤,3 位小数)
*
* 计价单位本身是重量单位时订货量即重量;否则从规格(如「10斤/箱」「500g/袋」)
* 解析每件重量再乘订货量;规格无法解析时计 0
*/
public static function estimate(string $spec, string $unit, string $quantity): string
{
return bcmul($quantity === '' ? '0' : $quantity, self::perUnitJin($spec, $unit), 3);
}
/**
* 单个计价单位折算重量(斤)
*/
private static function perUnitJin(string $spec, string $unit): string
{
// 按重量计价的单位:订货量本身就是重量
$unitWeight = self::toJin('1', $unit);
if ($unitWeight !== null) {
return $unitWeight;
}
// 从规格解析每件重量(如「10斤/箱」「500g/袋」「1.5kg/箱」)
if (preg_match('/(\d+(?:\.\d+)?)\s*(公斤|千克|kg|克|斤|g)/iu', $spec, $matches) === 1) {
$parsed = self::toJin($matches[1], $matches[2]);
if ($parsed !== null) {
return $parsed;
}
}
// 规格中的裸数字按斤计(如「10/箱」);无法解析计 0
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1) {
return $matches[0];
}
return '0';
}
/**
* 数值按单位折算为斤;非重量单位返回 null(1斤=500g1公斤/千克/kg=2斤)
*/
private static function toJin(string $value, string $unit): ?string
{
return match (mb_strtolower(trim($unit))) {
'斤' => $value,
'公斤', '千克', 'kg' => bcmul($value, '2', 3),
'克', 'g' => bcdiv($value, '500', 3),
default => null,
};
}
}
+11 -3
View File
@@ -45,12 +45,18 @@ return [
/*
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
* 公众号(mp):网页授权换 openid、公众号 JSAPI 支付使用,后台 site_config wechat.mp_appid / wechat.mp_secret 优先
*/
'wechat' => [
'mini' => [
'appid' => env('WECHAT_MINI_APPID', ''),
'secret' => env('WECHAT_MINI_SECRET', ''),
],
'mp' => [
'appid' => env('WECHAT_MP_APPID', ''),
'secret' => env('WECHAT_MP_SECRET', ''),
],
],
/*
@@ -63,7 +69,7 @@ return [
/*
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置
* 优先读取后台「系统设置 → 旺铺支付」(site_config wangpu.*),为空时回退到这里的 env 配置
*/
'wangpu' => [
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
@@ -74,12 +80,14 @@ return [
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(小程序主扫必填)
'mp_payway_code' => env('WANGPU_MP_PAYWAY_CODE', ''), // 公众号场景支付方式代码(留空回退 payway_code
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 支付/退款后台通知 MD5 专用加签Key(旺铺分配)
],
/*
* 微信官方支付(APIv3 小程序 JSAPI,基于 EasyWeChat Pay
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wxpay_*),为空时回退到这里的 env 配置
* 优先读取后台「系统设置 → 微信官方支付」(site_config wxpay.*),为空时回退到这里的 env 配置
* 密钥/证书支持:完整 PEM 文本、base64 单行、或以 file:// 开头的文件路径
*/
'wxpay' => [
@@ -43,6 +43,7 @@ return new class extends Migration
$table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
$table->string('mp_openid', 64)->default('')->comment('微信公众号 openid(公众号 JSAPI 支付付款人标识,网页授权换取后绑定)');
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->nullable()->default('')->comment('备注');
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 采购单单品对账标记:商品明细「已对账」勾选入库持久化(替代原前端 localStorage 本地标记)
*/
public function up(): void
{
if (! Schema::hasTable('purchase_item_check')) {
Schema::create('purchase_item_check', function (Blueprint $table) {
$table->increments('id')->comment('标记ID');
$table->integer('purchase_id')->comment('采购单ID');
$table->integer('product_id')->comment('商品ID');
$table->integer('operator_id')->default(0)->comment('标记人(后台系统用户ID');
$table->timestamps();
$table->unique(['purchase_id', 'product_id'], 'purchase_item_check_unique');
$table->comment('采购单单品对账标记表(商品明细「已对账」勾选)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchase_item_check');
}
};
+34 -18
View File
@@ -17,7 +17,10 @@ class SysDataSeeder extends Seeder
DB::table('sys_site_config_group')->insert([
['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date],
['id' => 3, 'title' => '业务配置', 'key' => 'services', 'remark' => '业务附加配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '网站的支付配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '收款方式与在线支付渠道配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 5, 'title' => '旺铺支付', 'key' => 'wangpu', 'remark' => '旺铺支付网关配置(进件入网后由旺铺分配)', 'created_at' => $date, 'updated_at' => $date],
['id' => 6, 'title' => '微信官方支付', 'key' => 'wxpay', 'remark' => '微信支付 APIv3 商户配置', 'created_at' => $date, 'updated_at' => $date],
['id' => 7, 'title' => '微信应用配置', 'key' => 'wechat', 'remark' => '微信小程序/公众号应用凭证配置', 'created_at' => $date, 'updated_at' => $date],
]);
DB::table('sys_site_config_items')->insert([
['id' => 1, 'group_id' => 1, 'key' => 'title', 'title' => '网站标题', 'describe' => '网站标题,用于展示在网站logo旁边和登录页面以及网页title中', 'values' => 'Xin Admin', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date,],
@@ -26,26 +29,39 @@ class SysDataSeeder extends Seeder
['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 36, 'group_id' => 3, 'key' => 'order_time_start', 'title' => '下单开始时间', 'describe' => '允许下单的开始时间(24小时制 HH:mm,如 08:00),留空不限制;开始时间晚于截单时间表示跨天时段(如 20:00 至次日 06:00', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 37, 'group_id' => 3, 'key' => 'order_time_end', 'title' => '截单时间', 'describe' => '下单截止时间(24小时制 HH:mm,如 18:00),不在下单时段内门店不能下单,留空不限制', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
// 支付配置(pay):收款方式与在线支付渠道
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 12, 'group_id' => 4, 'key' => 'wangpu_base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 13, 'group_id' => 4, 'key' => 'wangpu_organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
['id' => 19, 'group_id' => 4, 'key' => 'wangpu_payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
['id' => 20, 'group_id' => 4, 'key' => 'wangpu_private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 11, 'created_at' => $date, 'updated_at' => $date],
['id' => 21, 'group_id' => 4, 'key' => 'online_channel', 'title' => '在线支付渠道', 'describe' => '小程序在线支付通道(二选一):旺铺支付网关 或 微信官方支付(APIv3),切换仅影响之后发起的支付单', 'values' => 'wangpu', 'type' => 'Radio','options' => "wangpu=旺铺支付\nwechat=微信官方支付", 'sort' => 12, 'created_at' => $date, 'updated_at' => $date],
['id' => 22, 'group_id' => 4, 'key' => 'wxpay_mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 13, 'created_at' => $date, 'updated_at' => $date],
['id' => 23, 'group_id' => 4, 'key' => 'wxpay_appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 14, 'created_at' => $date, 'updated_at' => $date],
['id' => 24, 'group_id' => 4, 'key' => 'wxpay_private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 15, 'created_at' => $date, 'updated_at' => $date],
['id' => 25, 'group_id' => 4, 'key' => 'wxpay_certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 16, 'created_at' => $date, 'updated_at' => $date],
['id' => 26, 'group_id' => 4, 'key' => 'wxpay_secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 17, 'created_at' => $date, 'updated_at' => $date],
['id' => 27, 'group_id' => 4, 'key' => 'wxpay_platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 18, 'created_at' => $date, 'updated_at' => $date],
['id' => 28, 'group_id' => 4, 'key' => 'wxpay_platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 19, 'created_at' => $date, 'updated_at' => $date],
['id' => 21, 'group_id' => 4, 'key' => 'online_channel', 'title' => '在线支付渠道', 'describe' => '小程序在线支付通道(二选一):旺铺支付网关 或 微信官方支付(APIv3),切换仅影响之后发起的支付单', 'values' => 'wangpu', 'type' => 'Radio','options' => "wangpu=旺铺支付\nwechat=微信官方支付", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 29, 'group_id' => 4, 'key' => 'pay_methods', 'title' => '支付方式开关', 'describe' => '小程序付款页启用的支付方式(勾选展示),接口 /mini/payment/config 返回 pay_methods 供小程序端控制展示', 'values' => '["wechat_qrcode","alipay_qrcode","bank","online"]', 'type' => 'Checkbox','options' => "wechat_qrcode=微信收款码\nalipay_qrcode=支付宝收款码\nbank=对公汇款\nonline=在线支付", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
// 旺铺支付(wangpu):旺铺支付网关
['id' => 12, 'group_id' => 5, 'key' => 'base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 13, 'group_id' => 5, 'key' => 'organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 14, 'group_id' => 5, 'key' => 'mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 15, 'group_id' => 5, 'key' => 'mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 16, 'group_id' => 5, 'key' => 'term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
['id' => 17, 'group_id' => 5, 'key' => 'public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
['id' => 34, 'group_id' => 5, 'key' => 'mp_payway_code', 'title' => '旺铺公众号支付方式代码', 'describe' => '公众号 H5 场景支付方式代码 payway_code(如 WECHAT_JSPAY),留空则回退小程序支付方式代码', 'values' => 'WECHAT_JSPAY', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
['id' => 35, 'group_id' => 5, 'key' => 'sign_key', 'title' => '旺铺通知加签Key', 'describe' => '支付/退款后台通知 MD5 专用加签Key(旺铺分配),用于校验通知报文签名 sign,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
// 微信官方支付(wxpay):微信支付 APIv3
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 23, 'group_id' => 6, 'key' => 'appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 24, 'group_id' => 6, 'key' => 'private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 25, 'group_id' => 6, 'key' => 'certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 26, 'group_id' => 6, 'key' => 'secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
['id' => 27, 'group_id' => 6, 'key' => 'platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准)', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
['id' => 28, 'group_id' => 6, 'key' => 'platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
// 微信应用配置(wechat):小程序与公众号应用凭证
['id' => 30, 'group_id' => 7, 'key' => 'mini_appid', 'title' => '小程序AppID', 'describe' => '微信小程序 AppIDwx.login 换 openid、在线支付下单使用),留空回退 env WECHAT_MINI_APPID', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 31, 'group_id' => 7, 'key' => 'mini_secret', 'title' => '小程序AppSecret', 'describe' => '微信小程序 AppSecretcode2session 登录凭证校验),请勿泄露,留空回退 env WECHAT_MINI_SECRET', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 32, 'group_id' => 7, 'key' => 'mp_appid', 'title' => '公众号AppID', 'describe' => '微信公众号 AppID(公众号消息/网页授权等扩展能力使用)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 33, 'group_id' => 7, 'key' => 'mp_secret', 'title' => '公众号AppSecret', 'describe' => '微信公众号 AppSecret,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
]);
// 字典类型初始数据
DB::table('sys_dict')->insert([
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/check/${n}`,method:`put`})}async function o(t,n,r){return e({url:`/purchase/order/${t}/check`,method:`put`,data:{product_ids:n,checked:r}})}async function s(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function c(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function l(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function d(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function f(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function p(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function m(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function h(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{h as a,c,f as d,a as f,m as i,r as l,d as m,o as n,l as o,i as p,p as r,n as s,u as t,s as u};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function o(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function s(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function c(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function l(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function d(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function f(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function p(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{s as a,r as c,i as d,l as f,p as i,a as l,d as n,n as o,f as r,o as s,c as t,u};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XinAdmin</title>
<script type="module" crossorigin src="/assets/index-B3rYtk3Q.js"></script>
<script type="module" crossorigin src="/assets/index-CrAEpRIR.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
@@ -92,7 +92,7 @@
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
<link rel="stylesheet" crossorigin href="/assets/index-BeViyrx7.css">
<link rel="stylesheet" crossorigin href="/assets/index-Bz9U6gHM.css">
</head>
<body>
<div id="root"></div>
+84
View File
@@ -10,6 +10,9 @@ use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Services\SysSiteConfigService;
/**
* 账单链路与支付:订单状态随业务链自动推进(采购单完成→配送中、生成账单→已完成);
@@ -130,6 +133,56 @@ class BillPaymentTest extends ProcurementTestCase
$this->assertSame('5.00', (string) $bill2->total_amount, '未传售后金额不影响总金额');
}
/** 售后金额按客户等级上浮折算:输入 15、上浮 1% → 实收 15.15billPrepare 回显等级(负数同比例放大) */
public function test_generate_bill_after_sale_marked_up_by_level(): void
{
$level = CustomerLevelModel::factory()->create(['percent' => 1]);
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$level2 = CustomerLevelModel::factory()->create(['percent' => 2]);
$store2 = StoreModel::factory()->create(['level_id' => $level2->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
// 两店各下一单:店1 等级价 5.05 × 4 = 20.20;店2 等级价 5.10 × 2 = 10.20
foreach ([[$store, 4], [$store2, 2]] as [$s, $qty]) {
$this->actingAsMiniStore($s);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true);
// billPrepare 回显客户等级与上浮比例(弹窗预览用)
$prepare = $this->getJson("/purchase/order/{$purchase->id}/bill/prepare")
->assertJsonPath('success', true)
->json('data.stores');
$prepareByStore = collect($prepare)->keyBy('store_id');
$this->assertSame($level->name, $prepareByStore[$store->id]['level_name']);
$this->assertSame('1.00', (string) $prepareByStore[$store->id]['level_percent']);
$this->assertSame($level2->name, $prepareByStore[$store2->id]['level_name']);
$this->assertSame('2.00', (string) $prepareByStore[$store2->id]['level_percent']);
// 店1 售后 15 × 101% = 15.15;店2 售后 -10 × 102% = -10.20
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [
['store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => 15],
['store_id' => $store2->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => -10],
],
])->assertJsonPath('success', true);
$bill = BillModel::where('store_id', $store->id)->first();
$this->assertSame('15.15', (string) $bill->after_sale, '15 × 101%');
$this->assertSame('35.35', (string) $bill->total_amount, '商品 20.20 + 售后 15.15');
$bill2 = BillModel::where('store_id', $store2->id)->first();
$this->assertSame('-10.20', (string) $bill2->after_sale, '-10 × 102%(负数同比例放大)');
$this->assertSame('0.00', (string) $bill2->total_amount, '商品 10.20 + 售后 -10.20');
}
/** 生成账单:售后金额为正数时加收计入总金额 */
public function test_generate_bill_positive_after_sale_added_to_total(): void
{
@@ -374,4 +427,35 @@ class BillPaymentTest extends ProcurementTestCase
$this->assertStringContainsString('voucher/a.jpg', $data['payment']['voucher_urls'][0]);
$this->assertStringContainsString('voucher/b.jpg', $data['payment']['voucher_urls'][1], '凭证顺序保持提交顺序');
}
/** 支付配置接口:返回支付方式开关(未配置时默认全部启用,配置后按勾选项返回) */
public function test_payment_config_returns_pay_methods(): void
{
$store = StoreModel::factory()->create();
$this->actingAsMiniStore($store);
// 未配置时默认全部启用
$this->getJson('/mini/payment/config')
->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('data.pay_methods', ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online']);
// 后台勾选部分支付方式
$group = SysSiteConfigGroupModel::create(['title' => '支付配置', 'key' => 'pay', 'remark' => '']);
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => 'pay_methods',
'title' => '支付方式开关',
'describe' => '',
'values' => '["bank","online"]',
'type' => 'Checkbox',
'options' => '',
'sort' => 0,
]);
SysSiteConfigService::refreshSiteConfig();
$this->getJson('/mini/payment/config')
->assertOk()
->assertJsonPath('data.pay_methods', ['bank', 'online']);
}
}
+48 -4
View File
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/**
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
@@ -188,6 +189,48 @@ class ExportTest extends ProcurementTestCase
);
}
/** 商品明细导出:序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出) */
public function test_export_hides_detail_columns_by_default(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$export = new PurchaseOrderExport($purchase);
$sheet = (new Spreadsheet())->getActiveSheet();
$export->styles($sheet);
// 序号A/分类B/包规F/单位G/成本H/单价I/实际称重K/金额L 默认隐藏
foreach (['A', 'B', 'F', 'G', 'H', 'I', 'K', 'L'] as $column) {
$this->assertFalse($sheet->getColumnDimension($column)->getVisible(), "{$column} 应默认隐藏");
}
// 品名/供应商/市场/数量与门店列保持可见
foreach (['C', 'D', 'E', 'J', 'M', 'N'] as $column) {
$this->assertTrue($sheet->getColumnDimension($column)->getVisible(), "{$column} 应保持可见");
}
// 数据照常导出:列头与合计行仍含隐藏列数据
$rows = $export->collection()->values();
$header = $rows[3];
$this->assertSame('序号', $header[0]);
$this->assertSame('金额', $header[11]);
$total = $rows->last();
$this->assertSame('合计', $total[2]);
$this->assertSame(45.0, (float) $total[11]);
}
/** 商品明细导出:范围=全部未删除商品(含下架)∪ 本采购单有订货商品;已删除且无订货的商品不导出 */
public function test_export_scope_includes_off_shelf_but_not_deleted(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$offShelf = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF, 'cost_price' => '8.00']);
$deleted = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
$deleted->delete();
$rows = (new PurchaseOrderExport($purchase))->collection()->values();
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
$this->assertContains($offShelf->name, $names);
$this->assertNotContains($deleted->name, $names);
}
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
public function test_export_supplier_filter(): void
{
@@ -307,8 +350,9 @@ class ExportTest extends ProcurementTestCase
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
return false;
}
$totalA = $rowsA->last();
if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) {
// 无合计行:末行即最后一条明细
$lastA = $rowsA->last();
if ($lastA[0] === '合计') {
return false;
}
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A
@@ -366,10 +410,10 @@ class ExportTest extends ProcurementTestCase
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
return false;
}
// 每个工作表仅含本市场商品
// 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
foreach ($sheets as $sheet) {
$rows = $sheet->collection()->values();
$names = $rows->slice(3, -1)->map(static fn ($row) => $row[0])->values()->all();
$names = $rows->slice(3)->map(static fn ($row) => $row[0])->values()->all();
$expected = match (true) {
str_ends_with($sheet->title(), '新发地') => [$vegA->name],
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
+336
View File
@@ -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=公众号 appidpackage 含 prepay_idH5 喂 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.openidmp_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);
}
}
+70 -25
View File
@@ -17,7 +17,8 @@ use ReflectionMethod;
* 小程序在线支付(旺铺网关行业版):下单 调起支付 后台通知/主动查询结账
*
* 通讯协议与官方示例 demo/IndexController.php 一致:
* 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输
* 下单/查询的业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输
* 支付结果后台通知为明文表单报文,仅 MD5 验签(ASCII 升序拼接 + 专用加签Key)。
*
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
@@ -44,6 +45,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
'services.wangpu.payway_code' => 'WECHAT_MINI',
'services.wangpu.sign_key' => 'notify-test-key',
]);
}
@@ -106,6 +108,14 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'order_id' => 'WP202608270001',
'tradeNo' => 'T20260827001',
'user_openid' => $openid,
'wxjsapistr' => (string) json_encode([
'appId' => 'wx-mini-test',
'timeStamp' => '1724745600',
'nonceStr' => 'nonce001',
'package' => 'prepay_id=wp-prepay-001',
'signType' => 'RSA',
'paySign' => 'sign001',
]),
]),
200
),
@@ -151,10 +161,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
return $payment;
}
/** 构造加密的支付成功通知信封 */
private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array
/** 构造带 MD5 签名的支付成功通知表单报文(加签算法与官方文档一致,测试侧独立实现) */
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
{
return $this->gatewayEnvelope(array_merge([
$params = array_merge([
'mer_order_id' => $payment->payment_no,
'order_status' => '1',
'order_amt' => (string) $payment->amount,
@@ -166,7 +176,13 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
'mer_no' => 'mer001',
'device_no' => 'dev001',
'order_title' => '账单合并付款',
], $overrides));
], $overrides);
$signParams = array_filter($params, static fn ($value): bool => $value !== null && $value !== '');
ksort($signParams, SORT_STRING);
$str = implode('&', array_map(static fn ($k, $v) => $k . '=' . $v, array_keys($signParams), $signParams));
$params['sign'] = strtoupper(md5($str . '&key=' . config('services.wangpu.sign_key')));
return $params;
}
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
@@ -186,19 +202,41 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
);
}
/** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */
public function test_notify_envelope_round_trip(): void
/** 通知 MD5 验签 golden test:官方文档示例报文 + SignKey 直算签名一致(含中文、空值剔除) */
public function test_notify_sign_matches_doc_golden(): void
{
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
$params = [
'mer_order_id' => 'ZF202608270001',
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
'trade_no' => '11420200410120144102483',
'mer_no' => '2001071119360E5Riu',
'order_amt' => '0.01',
'payway_code' => 'QR_WECHAT_BARPAY',
'order_id' => '202004101201444525348059',
'order_status' => '1',
'order_amt' => '150.50',
'order_title' => '账单合并付款', // 中文报文
'order_title' => '住宿酒店',
'mer_code' => 'W00000000001381',
'device_no' => 'CBC92E5GTL000083',
'order_time' => '2020-04-10 12:01:44',
'trade_time' => '2020-04-10 12:01:47',
'gateway_mer_order_id' => '2020041012014445269',
'fee' => '', // 空值不参与签名(平台不下发空值数据元)
'sign' => 'A31998F2E0549E0A80B2A4B3A0473784', // 文档示例签名值
];
$decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params));
$verified = app(WangpuPayService::class)->verifyNotify($params);
$this->assertSame($params, $decrypted);
$this->assertSame($params, $verified);
}
/** 通知验签失败(签名缺失/错误)抛异常 */
public function test_notify_verify_rejects_bad_sign(): void
{
$service = app(WangpuPayService::class);
$this->expectException(RepositoryException::class);
$service->verifyNotify(['mer_order_id' => 'ZF202608270001', 'sign' => 'INVALIDSIGN']);
}
/** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */
@@ -228,7 +266,9 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$paymentNo = $response->json('data.payment_no');
$this->assertSame('150.50', $response->json('data.amount'));
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
// 调起支付参数取自网关应答 wxjsapistr(小程序 wx.requestPayment 直接透传)
$this->assertSame('wx-mini-test', $response->json('data.pay_params.appId'));
$this->assertSame('prepay_id=wp-prepay-001', $response->json('data.pay_params.package'));
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
@@ -336,7 +376,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
}
/** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
/** 支付成功通知:验签 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
public function test_notify_settles_payment(): void
{
$store = StoreModel::factory()->create();
@@ -344,7 +384,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
$bill2 = $this->makeBill($store, '50.00');
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$payment->refresh();
@@ -366,34 +406,39 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
);
// 重复通知幂等:仍应答成功,金额不重复累加
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
}
/** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
/** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
public function test_notify_rejects_invalid_messages(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '100.00');
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
// 信封 signature 非法 → 解密失败
$badEnvelope = $this->encryptedNotifyParams($payment);
$badEnvelope['signature'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01');
// 签名非法 → 验签失败
$badSign = $this->signedNotifyParams($payment);
$badSign['sign'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
// 金额不一致(防篡改)
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99']))
// 篡改金额后未重签 → 验签失败
$tampered = $this->signedNotifyParams($payment);
$tampered['order_amt'] = '99.99';
$this->postJson('/mini/payment/notify', $tampered)->assertJsonPath('code', '01');
// 金额不一致(防篡改,签名正确但金额与支付单不符)
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
->assertJsonPath('code', '01');
// 订单号不存在
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
->assertJsonPath('code', '01');
// 非支付成功状态
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0']))
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
->assertJsonPath('code', '01');
// 均未结账
+22
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\StoreModel;
@@ -105,6 +106,27 @@ class MiniProductTest extends ProcurementTestCase
$this->assertNull($row['price']);
}
/** 商品列表:先按分类树展示顺序(深度优先),同分类内按商品 sort 升序、id 升序,未入树分类排最后 */
public function test_product_list_ordered_by_category_tree(): void
{
$rootA = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$rootB = ProductCategoryModel::create(['name' => '水果', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
// 子分类 sort 与创建顺序相反,验证按 sort 而非 id 排序
$leafA1 = ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $rootA->id, 'sort' => 1, 'status' => 1]);
$leafA2 = ProductCategoryModel::create(['name' => '根茎', 'parent_id' => $rootA->id, 'sort' => 0, 'status' => 1]);
$pB = ProductModel::factory()->create(['category_id' => $rootB->id, 'status' => 1]);
$pA1 = ProductModel::factory()->create(['category_id' => $leafA1->id, 'status' => 1]);
$pA2Low = ProductModel::factory()->create(['category_id' => $leafA2->id, 'status' => 1, 'sort' => 1]);
$pA2High = ProductModel::factory()->create(['category_id' => $leafA2->id, 'status' => 1, 'sort' => 9]);
$pNone = ProductModel::factory()->create(['category_id' => 0, 'status' => 1]);
$ids = array_column($this->getJson('/mini/product/list')->assertOk()->json('data.data'), 'id');
// 树序:rootA → leafA2 → leafA1 → rootBleafA2 内 sort 升序;无分类排最后
$this->assertSame([$pA2Low->id, $pA2High->id, $pA1->id, $pB->id, $pNone->id], $ids);
}
/** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */
public function test_product_list_appends_cart_quantity_and_summary(): void
{
+182 -2
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseItemCheckModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -282,7 +283,7 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('50.00', (string) $order->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('4.500', (string) $purchase->total_weight);
$this->assertSame('7.500', (string) $purchase->total_weight, '4.500 手动称重 + B 店参考重量 3.000');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
}
@@ -392,7 +393,7 @@ class PurchaseEditTest extends ProcurementTestCase
$purchase = $purchase->fresh();
$this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4');
$this->assertSame('82.00', (string) $purchase->estimate_amount, '50+324 包 × 成本 8.00');
$this->assertSame('1.500', (string) $purchase->total_weight);
$this->assertSame('6.500', (string) $purchase->total_weight, '原有参考重量 2+3 斤 + 新增 1.500');
// 重复添加同一商品 → 拒绝
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
@@ -402,6 +403,28 @@ class PurchaseEditTest extends ProcurementTestCase
->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量');
}
/** 门店购买详情-新增单品未传称重:按订货量 × 规格预填参考重量并级联汇总 */
public function test_store_item_add_defaults_reference_weight(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$extra = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 8,
'spec' => '25斤/袋',
'unit' => '袋',
]);
$this->actingAsSysUser();
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $extra->id,
'quantity' => 2,
])->assertOk()->assertJsonPath('success', true);
$item = StoreOrderItemModel::where('product_id', $extra->id)->first();
$this->assertSame('50.000', (string) $item->weight, '2 袋 × 25斤/袋');
$this->assertSame('55.000', (string) $purchase->fresh()->total_weight, '原有参考重量 5.000 + 新增 50.000');
}
/** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */
public function test_store_item_update_merges_multi_order_rows(): void
{
@@ -585,4 +608,161 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
$this->assertSame('10.00', (string) $item->fresh()->cost_price);
}
/** 单品「已对账」标记:切换入库持久化,详情接口回显,再次切换取消 */
public function test_item_check_toggle_persists_and_echoes_in_detail(): void
{
[$purchase, $product] = $this->buildPurchase();
$this->actingAsSysUser();
// 初始无标记
$this->getJson("/purchase/order/{$purchase->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked_product_ids', []);
// 标记 → 入库
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', true);
$this->assertDatabaseHas('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
// 详情回显
$this->getJson("/purchase/order/{$purchase->id}")
->assertJsonPath('data.checked_product_ids.0', $product->id);
// 再次切换 → 取消标记,记录删除
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', false);
$this->assertDatabaseMissing('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
}
/** 对账标记与采购单状态无关:已完成采购单仍可切换标记 */
public function test_item_check_allowed_when_purchase_completed(): void
{
[$purchase, $product] = $this->buildPurchase();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/check/{$product->id}")
->assertJsonPath('success', true)
->assertJsonPath('data.checked', true);
}
/** 对账标记校验:采购单不存在 / 采购单内无此商品均报错,不入库 */
public function test_item_check_toggle_validates_purchase_and_product(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson('/purchase/order/99999/check/1')
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
$this->putJson("/purchase/order/{$purchase->id}/check/99999")
->assertJsonPath('success', false)
->assertJsonPath('msg', '该采购单下无此商品的订货明细');
$this->assertSame(0, PurchaseItemCheckModel::count());
}
/** 批量标记(全选):一次标记多个商品,重复标记幂等,批量取消仅清指定商品 */
public function test_item_check_batch_marks_and_clears(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
// 采购单内再加一个商品(通过新增单品端点挂靠门店订单)
$product2 = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 5,
'spec' => '1斤',
'unit' => '斤',
]);
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $product2->id,
'quantity' => 1,
])->assertJsonPath('success', true);
// 批量标记两个商品
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id, $product2->id],
'checked' => true,
])->assertJsonPath('success', true)
->assertJsonPath('data.count', 2);
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
// 重复批量标记幂等(不产生重复行)
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id, $product2->id],
'checked' => true,
])->assertJsonPath('success', true);
$this->assertSame(2, PurchaseItemCheckModel::where('purchase_id', $purchase->id)->count());
// 详情回显两个标记
$ids = $this->getJson("/purchase/order/{$purchase->id}")->json('data.checked_product_ids');
$this->assertEqualsCanonicalizing([$product->id, $product2->id], $ids);
// 批量取消其中一个
$this->putJson("/purchase/order/{$purchase->id}/check", [
'product_ids' => [$product->id],
'checked' => false,
])->assertJsonPath('success', true);
$this->assertDatabaseMissing('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product->id,
]);
$this->assertDatabaseHas('purchase_item_check', [
'purchase_id' => $purchase->id,
'product_id' => $product2->id,
]);
}
/** 批量标记校验:空列表/采购单不存在报错;无效商品静默忽略 */
public function test_item_check_batch_validates_and_ignores_unknown_products(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [], 'checked' => true])
->assertJsonPath('success', false);
$this->putJson('/purchase/order/99999/check', ['product_ids' => [1], 'checked' => true])
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
// 采购单内无此商品 → 静默忽略不入库
$this->putJson("/purchase/order/{$purchase->id}/check", ['product_ids' => [99999], 'checked' => true])
->assertJsonPath('success', true)
->assertJsonPath('data.count', 0);
$this->assertSame(0, PurchaseItemCheckModel::count());
}
/** 采购单列表「应付商品金额」列 = Σ 已生成门店账单的商品金额(未生成账单为 null) */
public function test_list_includes_bill_product_amount(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$this->actingAsSysUser();
// 未生成账单 → null
$this->getJson('/purchase/order')
->assertJsonPath('data.data.0.bill_product_amount', null);
// 完成采购单并生成两店账单(各店商品金额 = 数量 × 等级价 10.00)
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [
['store_id' => $stores[0]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
['store_id' => $stores[1]->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0],
],
])->assertJsonPath('success', true);
// 列表回显:2×10.00 + 3×10.00 = 50.00
$this->getJson('/purchase/order')
->assertJsonPath('data.data.0.bill_product_amount', '50.00');
}
}
+26
View File
@@ -139,4 +139,30 @@ class PurchaseGenerateTest extends ProcurementTestCase
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
}
/** 采购单总重量 = 订货明细参考重量合计(下单时已按订货量 × 规格预填) */
public function test_generate_sums_reference_weight(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '6.00',
'spec' => '10斤/箱',
'unit' => '箱',
]);
$this->actingAsMiniStore($store);
foreach ([3, 4] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$this->assertSame('70.000', (string) PurchaseOrderModel::first()->total_weight, '(3+4) 箱 × 10斤/箱');
}
}
+127
View File
@@ -9,6 +9,9 @@ use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Services\SysSiteConfigService;
/**
* 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离
@@ -72,6 +75,40 @@ class StoreOrderTest extends ProcurementTestCase
$this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额');
}
/** 下单预填参考重量 = 订货量 × 规格折算(按重量计价的商品订货量即重量),订单总重量 = 明细合计 */
public function test_place_order_prefills_reference_weight(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$byBox = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '5.00',
'spec' => '10斤/箱',
'unit' => '箱',
]);
$byJin = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '5.00',
'spec' => '散装',
'unit' => '斤',
]);
$this->actingAsMiniStore($store);
$this->postJson('/mini/order', [
'items' => [
['product_id' => $byBox->id, 'quantity' => 3],
['product_id' => $byJin->id, 'quantity' => 5],
],
])->assertOk()->assertJsonPath('success', true);
$order = StoreOrderModel::where('store_id', $store->id)->first();
$boxItem = $order->items->firstWhere('product_id', $byBox->id);
$jinItem = $order->items->firstWhere('product_id', $byJin->id);
$this->assertSame('30.000', (string) $boxItem->weight, '3 箱 × 10斤/箱');
$this->assertSame('5.000', (string) $jinItem->weight, '按斤计价:订货量即重量');
$this->assertSame('35.000', (string) $order->total_weight, '订单总重量 = 明细参考重量合计');
}
/** 门店绑定的客户等级被删除时拒绝下单 */
public function test_store_level_missing_rejected(): void
{
@@ -392,6 +429,96 @@ class StoreOrderTest extends ProcurementTestCase
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $pending1->refresh()->status);
}
/** 写入下单时段业务配置并刷新缓存(value 传 null 表示该项不配置) */
private function setOrderTimeWindow(?string $start, ?string $end): void
{
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services', 'remark' => '']);
foreach (['order_time_start' => $start, 'order_time_end' => $end] as $key => $value) {
if ($value === null) {
continue;
}
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => $key,
'title' => $key,
'describe' => '',
'values' => $value,
'type' => 'Input',
'options' => '',
'sort' => 0,
]);
}
SysSiteConfigService::refreshSiteConfig();
}
/** 截单时间:下单时段外禁止下单,时段内正常下单 */
public function test_place_order_blocked_outside_order_time_window(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow('08:00', '18:00');
$this->actingAsMiniStore($store);
// 20:00 已截单 → 拒绝且不生成订单
$this->travelTo('2026-09-05 20:00:00');
$response = $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
$response->assertOk()->assertJsonPath('success', false);
$this->assertStringContainsString('不在下单时段内', (string) $response->json('msg'));
$this->assertStringContainsString('08:00 - 18:00', (string) $response->json('msg'));
$this->assertSame(0, StoreOrderModel::count());
// 边界:08:00 开始时间与 18:00 截单时间均可下单
$this->travelTo('2026-09-05 08:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-05 18:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->assertSame(2, StoreOrderModel::count());
}
/** 截单时间:跨天时段(开始时间晚于截单时间,如 20:00-次日06:00 */
public function test_place_order_cross_midnight_time_window(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow('20:00', '06:00');
$this->actingAsMiniStore($store);
// 当晚 22:00 与 次日 05:00 均在时段内
$this->travelTo('2026-09-05 22:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-06 05:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
// 中午 12:00 不在时段内 → 拒绝
$this->travelTo('2026-09-06 12:00:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', false);
$this->assertSame(2, StoreOrderModel::count());
}
/** 截单时间:只配置截单时间时按单边限制(截止后不能下单);格式非法按未配置处理 */
public function test_place_order_only_cutoff_time_and_invalid_config(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$this->setOrderTimeWindow(null, '18:00');
$this->actingAsMiniStore($store);
$this->travelTo('2026-09-05 17:59:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$this->travelTo('2026-09-05 18:01:00');
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', false);
// 非法格式不拦截(避免误配置导致全天无法下单)
SysSiteConfigItemsModel::query()->update(['values' => 'abc']);
SysSiteConfigService::refreshSiteConfig();
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
}
/** 订单列表商品预览:附带首图/规格/单位供小程序端展示 */
public function test_order_list_preview_includes_image_spec_and_unit(): void
{
+41
View File
@@ -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'));
}
}
+17
View File
@@ -73,6 +73,23 @@ export async function getPurchaseCell(purchaseId: number, productId: number, sto
});
}
/** 切换单品「已对账」标记(入库持久化,再次调用取消标记) */
export async function togglePurchaseItemCheck(purchaseId: number, productId: number) {
return createAxios<{ checked: boolean }>({
url: `/purchase/order/${purchaseId}/check/${productId}`,
method: 'put',
});
}
/** 批量设置单品「已对账」标记(全选:checked=true 批量标记,false 批量取消) */
export async function batchPurchaseItemCheck(purchaseId: number, productIds: number[], checked: boolean) {
return createAxios<{ count: number }>({
url: `/purchase/order/${purchaseId}/check`,
method: 'put',
data: { product_ids: productIds, checked },
});
}
/** 门店订单明细修改(订货量、重量、单价),自动重算价格 */
export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) {
return createAxios({
+8
View File
@@ -74,6 +74,8 @@ export default interface IPurchaseOrder {
total_weight?: string;
estimate_amount?: string;
actual_amount?: string;
/** 应付商品金额 = Σ 已生成门店账单的商品金额(列表接口附;未生成账单为 null) */
bill_product_amount?: string | null;
operator_id?: number;
operator?: { id: number; nickname: string };
remark?: string;
@@ -87,6 +89,8 @@ export interface IPurchaseDetail {
items: IPurchaseDetailRow[];
/** 门店账单(采购单完成后按门店生成) */
bills: IBill[];
/** 单品「已对账」标记的商品ID列表(入库持久化) */
checked_product_ids: number[];
}
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
@@ -143,6 +147,10 @@ export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> =
export interface IBillPrepareStore {
store_id: number;
store_name: string;
/** 客户等级名(无等级为 null) */
level_name?: string | null;
/** 客户等级上浮比例(售后金额折算用;无等级为 '0' */
level_percent?: string;
order_count: number;
/** 商品金额(订单汇总,不可修改) */
product_amount: string;
+22 -21
View File
@@ -294,13 +294,30 @@ const ProductGoodsPage: React.FC = () => {
];
const columns: XinTableColumn<IProduct>[] = [
// {
// title: 'ID',
// dataIndex: 'id',
// hideInForm: true,
// hideInSearch: true,
// width: 70,
// align: 'center',
// },
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
title: '商品名称',
align: 'center',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
},
{
title: '商品图片',
@@ -335,22 +352,6 @@ const ProductGoodsPage: React.FC = () => {
hideInSearch: true,
colProps: { span: 24 },
},
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
},
{
title: '商品描述',
dataIndex: 'remark',
+206 -31
View File
@@ -17,9 +17,10 @@ import {
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd';
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import {AccountBookOutlined, CheckOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
@@ -42,6 +43,7 @@ import type {
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import {
addPurchaseStoreItem,
batchPurchaseItemCheck,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
@@ -50,6 +52,7 @@ import {
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
togglePurchaseItemCheck,
type BillGenerateStoreParams,
type PurchaseRowUpdateParams,
updatePurchaseRow,
@@ -83,6 +86,10 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
};
/** 售后金额按客户等级上浮折算:实收 = 输入 × (100+percent)/100(两位小数截断,与后端 calcLevelPrice 同口径) */
const calcLevelAmount = (amount: number, percent: number): number =>
Math.trunc(amount * (100 + percent)) / 100;
/**
* C1 / C2-C3 / C4
* ×
@@ -98,6 +105,10 @@ const PurchaseOrderPage: React.FC = () => {
const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false);
// 商品明细:单品「已对账」勾选标记(入库持久化,随详情接口回显)
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set());
const [checkAllSaving, setCheckAllSaving] = useState(false);
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
@@ -202,17 +213,20 @@ const PurchaseOrderPage: React.FC = () => {
const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + 售后金额(可正负)
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;售后金额 = 输入 × 客户等级上浮比例;总额 = 商品 + 配送费 + 附加 + 售后(可正负)
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {};
const deliveryFee = Number(input.delivery_fee ?? 0);
const afterSale = Number(input.after_sale ?? 0);
const levelPercent = Number(row.level_percent ?? 0);
const afterSaleInput = Number(input.after_sale ?? 0);
const afterSale = calcLevelAmount(afterSaleInput, levelPercent);
const added =
Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price);
return {
added,
afterSale,
total: Number(row.product_amount) + deliveryFee + added + afterSale,
};
});
@@ -246,6 +260,87 @@ const PurchaseOrderPage: React.FC = () => {
}
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
// 切换采购单时从详情接口恢复单品「已对账」标记(入库持久化)
useEffect(() => {
setCheckedItems(new Set(detail?.checked_product_ids ?? []));
}, [detail]);
/** 切换单品「已对账」标记(入库持久化;乐观更新,请求失败回滚本地勾选) */
const toggleItemChecked = async (productId: number) => {
if (!detail) {
return;
}
const willCheck = !checkedItems.has(productId);
setCheckedItems((prev) => {
const next = new Set(prev);
if (willCheck) {
next.add(productId);
} else {
next.delete(productId);
}
return next;
});
try {
await togglePurchaseItemCheck(detail.purchase.id!, productId);
} catch {
// 错误提示由请求封装统一弹出,这里回滚本地勾选
setCheckedItems((prev) => {
const next = new Set(prev);
if (willCheck) {
next.delete(productId);
} else {
next.add(productId);
}
return next;
});
}
};
/** 商品明细当前可见行的商品ID(列筛选联动,全选仅作用于筛选后的数据) */
const visibleProductIds = useMemo(() => summaryItems.map((row) => row.product_id), [summaryItems]);
/** 当前可见行是否全部已标记 */
const allVisibleChecked =
visibleProductIds.length > 0 && visibleProductIds.every((id) => checkedItems.has(id));
/** 全选/取消全选「已对账」标记(仅作用于列筛选后的可见行;乐观更新,请求失败回滚) */
const toggleAllVisibleChecked = async () => {
if (!detail || visibleProductIds.length === 0 || checkAllSaving) {
return;
}
const willCheck = !allVisibleChecked;
setCheckedItems((prev) => {
const next = new Set(prev);
visibleProductIds.forEach((id) => {
if (willCheck) {
next.add(id);
} else {
next.delete(id);
}
});
return next;
});
setCheckAllSaving(true);
try {
await batchPurchaseItemCheck(detail.purchase.id!, visibleProductIds, willCheck);
} catch {
// 错误提示由请求封装统一弹出,这里回滚本地勾选
setCheckedItems((prev) => {
const next = new Set(prev);
visibleProductIds.forEach((id) => {
if (willCheck) {
next.delete(id);
} else {
next.add(id);
}
});
return next;
});
} finally {
setCheckAllSaving(false);
}
};
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
@@ -403,7 +498,8 @@ const PurchaseOrderPage: React.FC = () => {
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
await loadDetail(detail.purchase.id!);
const res1 = await getPurchaseDetail(detail.purchase.id!);
setDetail(res1.data.data ?? null);
await tableRef.current?.reload();
};
@@ -424,8 +520,9 @@ const PurchaseOrderPage: React.FC = () => {
return;
}
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
const res = await getPurchaseDetail(detail.purchase.id!);
setDetail(res.data.data ?? null);
message.success('订货量已更新,订货单与采购单汇总已重算');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
};
@@ -562,20 +659,56 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
},
{
title: '市场',
title: (
<Space size={4}>
<span></span>
<Tooltip
title={`${allVisibleChecked ? '取消全选' : '全选对账'}(仅作用于当前筛选显示的 ${visibleProductIds.length} 行)`}
>
<Button
size="small"
shape="circle"
style={{ minWidth: 16, height: 16, width: 16 }}
icon={<CheckOutlined />}
color={allVisibleChecked ? 'green' : 'default'}
variant={allVisibleChecked ? 'solid' : 'outlined'}
loading={checkAllSaving}
disabled={visibleProductIds.length === 0}
onClick={() => void toggleAllVisibleChecked()}
/>
</Tooltip>
</Space>
),
fixed: 'left',
dataIndex: 'market',
width: 110,
width: 90,
align: 'center',
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
filteredValue: itemColumnFilters.market ?? null,
onFilter: (value, row) => (row.market ?? '') === value,
render: (v) => v || '-',
render: (v, row) => {
// ✔ 按钮标记本单品已对账(入库持久化,随详情接口回显)
const checked = checkedItems.has(row.product_id);
return (
<Space size={4}>
<span>{v || '-'}</span>
<Button
size="small"
shape="circle"
style={{ minWidth: 16, height: 16, width: 16 }}
icon={<CheckOutlined />}
color={checked ? 'green' : 'default'}
variant={checked ? 'solid' : 'outlined'}
onClick={() => void toggleItemChecked(row.product_id)}
/>
</Space>
);
},
},
{
title: '单价',
key: 'retail_price',
width: 110,
width: 90,
align: 'center',
render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
@@ -589,20 +722,20 @@ const PurchaseOrderPage: React.FC = () => {
title: '包规',
dataIndex: 'product_spec',
align: 'center',
width: 110,
width: 90,
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
},
{
title: '单位',
dataIndex: 'unit',
width: 110,
width: 90,
align: 'center',
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
},
{
title: '成本',
dataIndex: 'cost_price',
width: 110,
width: 120,
align: 'center',
render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`),
},
@@ -633,6 +766,7 @@ const PurchaseOrderPage: React.FC = () => {
key={`${row.product_id}-${store.id}-${quantity}`}
size="small"
min={0}
styles={{ input: { textAlign: 'center' }, root: {width: 80} }}
precision={0}
className="w-full"
defaultValue={quantity}
@@ -973,6 +1107,19 @@ const PurchaseOrderPage: React.FC = () => {
<Text type="secondary"></Text>
),
},
{
title: '应付商品金额',
dataIndex: 'bill_product_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) =>
record.bill_product_amount != null ? (
<Text strong type="danger">¥{Number(record.bill_product_amount).toFixed(2)}</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: '总件数',
dataIndex: 'total_quantity',
@@ -1275,6 +1422,9 @@ const PurchaseOrderPage: React.FC = () => {
</Button>
</AuthButton>
<Text type="secondary" className="text-xs">
{visibleProductIds.filter((id) => checkedItems.has(id)).length}/{visibleProductIds.length}
</Text>
{canUpdateRow && (
<Text type="secondary" className="text-xs">
@@ -1289,7 +1439,7 @@ const PurchaseOrderPage: React.FC = () => {
dataSource={detail.items}
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
pagination={false}
scroll={{ x: 1200, y: 800 }}
scroll={{ x: 900 + (detail?.stores.length * 100), y: 500 }}
summary={renderSummary}
/>
</>
@@ -1317,7 +1467,7 @@ const PurchaseOrderPage: React.FC = () => {
{billPrepare && (
<>
<div className="py-2 text-gray-500">
/====
/== 15 1% 15.15
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
@@ -1328,9 +1478,18 @@ const PurchaseOrderPage: React.FC = () => {
<div className="w-40 shrink-0"></div>
<div className="w-28 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
<div className="w-36 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">
<div></div>
<div>¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
</div>
<div className="w-32 shrink-0 text-center">
<div></div>
<div>¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
</div>
<div className="w-50 shrink-0 text-center">
<div></div>
<div></div>
</div>
<div className="w-36 shrink-0 text-center"></div>
<div className="w-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div>
@@ -1346,6 +1505,11 @@ const PurchaseOrderPage: React.FC = () => {
{row?.order_count ?? 0}
{billed && <Tag className="ml-1!" color="success"></Tag>}
</div>
<div className="text-xs text-gray-400">
{row?.level_name
? `${row.level_name} +${Number(row.level_percent ?? 0)}%`
: '无等级(不上浮)'}
</div>
</div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
@@ -1354,7 +1518,7 @@ const PurchaseOrderPage: React.FC = () => {
<Input />
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]}
>
@@ -1367,7 +1531,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转筐数量' }]}
>
@@ -1379,7 +1543,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
@@ -1390,17 +1554,28 @@ const PurchaseOrderPage: React.FC = () => {
placeholder="正压负回"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'after_sale']}
>
<InputNumber
className="w-full"
precision={2}
disabled={billed}
placeholder="可正负,计入总额"
/>
</Form.Item>
<div className="w-50 shrink-0 px-1! flex justify-center">
<Space.Compact block>
<Space.Addon>
{/* 售后金额按客户等级上浮折算预览(实收 = 输入 × (100+上浮%)/100);已生成账单行为存档值不再折算 */}
{(
<div className="text-xs text-orange-500 text-center">
¥{billPreview[field.name]?.afterSale.toFixed(2)}
</div>
)}
</Space.Addon>
<Form.Item className="m-0!" name={[field.name, 'after_sale']}>
<InputNumber
className="w-full"
precision={2}
prefix={'¥'}
disabled={billed}
placeholder="可正负,计入总额"
/>
</Form.Item>
</Space.Compact>
</div>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']}