Compare commits

...

5 Commits

Author SHA1 Message Date
xinadmin c3ec416a77 打包前端 2026-08-27 21:17:33 +08:00
xinadmin c35dac0055 在线支付 2026-08-27 21:17:02 +08:00
xinadmin 65eb8ef594 采购单样式修改 2026-08-27 18:57:16 +08:00
xinadmin f3e190f460 购物车悬浮 2026-08-27 18:13:43 +08:00
xinadmin 52dc6efadf 修复BUG 2026-08-27 14:20:09 +08:00
79 changed files with 2336 additions and 142 deletions
+14
View File
@@ -46,3 +46,17 @@ MAIL_USERNAME=
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_FROM_ADDRESS= MAIL_FROM_ADDRESS=
MAIL_FROM_NAME= MAIL_FROM_NAME=
# 微信小程序(code2session 换取 openid,在线支付必需)
WECHAT_MINI_APPID=
WECHAT_MINI_SECRET=
# 旺铺支付网关(也可在后台 系统设置→支付配置 中维护,后台配置优先)
WANGPU_BASE_URL=
WANGPU_ORGANIZ_NO=
WANGPU_MER_NO=
WANGPU_MER_CODE=
WANGPU_TERM_CODE=
WANGPU_SIGN_KEY=
WANGPU_SUB_APPID=
WANGPU_PAYWAY_CODE=WECHAT_MINI
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -130,7 +130,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
'product_spec' => $spec, 'product_spec' => $spec,
'unit' => (string) ($snapshot->unit ?? $product->unit), 'unit' => (string) ($snapshot->unit ?? $product->unit),
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price), 'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
// 参考零售价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空) // 价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空)
'retail_price' => $group !== null && (float) $quantity > 0 'retail_price' => $group !== null && (float) $quantity > 0
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec) ? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
: null, : null,
@@ -173,7 +173,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
// 列头 // 列头
$rows[] = array_merge( $rows[] = array_merge(
['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'], ['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '价', '数量', '实际称重', '金额'],
array_values($this->storeNames), array_values($this->storeNames),
); );
$this->specialRows[++$rowIndex] = 'header'; $this->specialRows[++$rowIndex] = 'header';
+18 -7
View File
@@ -6,11 +6,13 @@ use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use App\Services\PurchaseItemService;
use Maatwebsite\Excel\Concerns\WithMultipleSheets; use Maatwebsite\Excel\Concerns\WithMultipleSheets;
/** /**
* 供应商采购明细导出:多供应商合并为一个 XLSX(每供应商一个工作表,工作表名=供应商名称); * 供应商采购明细导出:多供应商合并为一个 XLSX,按「供应商 × 市场」拆分工作表
* 构造传入 supplierId 时仅导出该供应商(单工作表) * (工作表名=供应商·市场,市场取商品档案,未设置市场归入「未设置」);
* 构造传入 supplierId 时仅导出该供应商
*/ */
class PurchaseSupplierExport implements WithMultipleSheets class PurchaseSupplierExport implements WithMultipleSheets
{ {
@@ -51,14 +53,23 @@ class PurchaseSupplierExport implements WithMultipleSheets
->orderBy('id') ->orderBy('id')
->get(['id', 'name']); ->get(['id', 'name']);
$service = app(PurchaseItemService::class);
$usedNames = []; $usedNames = [];
$sheets = []; $sheets = [];
foreach ($suppliers as $supplier) { foreach ($suppliers as $supplier) {
$sheets[] = new PurchaseSupplierSheet( // 按市场拆分工作表
$this->purchase, $matrix = $service->supplierMarketMatrix($this->purchase->id, (int) $supplier->id);
$supplier, foreach ($matrix['markets'] as $market => $rows) {
SheetName::make((string) $supplier->name, (int) $supplier->id, $usedNames), $marketLabel = $market === '' ? '未设置' : $market;
); $sheets[] = new PurchaseSupplierSheet(
$this->purchase,
$supplier,
$marketLabel,
$rows,
$matrix['stores'],
SheetName::make($supplier->name . '·' . $marketLabel, (int) $supplier->id, $usedNames),
);
}
} }
return $sheets; return $sheets;
+67 -28
View File
@@ -4,20 +4,25 @@ namespace App\Exports;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use App\Services\PurchaseItemService;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection; use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison; use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles; use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle; use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/** /**
* 供应商采购明细导出 · 单供应商工作表(成本口径:金额=Σ数量×成本价) * 供应商采购明细导出 · 单市场工作表
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
* 有数据的单元格(品名/汇总/门店数量)填充突出颜色并加粗
*/ */
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
{ {
/** 有数据单元格填充色(浅黄,突出显示) */
private const string DATA_FILL = 'FFFFFF99';
private ?Collection $rows = null; private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */ /** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
@@ -26,15 +31,29 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
/** 列头所在行索引 */ /** 列头所在行索引 */
private int $headerRow = 1; private int $headerRow = 1;
/** @var array<int, array<int, int>> 有数据的单元格:行索引(1 起)=> 列索引(1 起)列表 */
private array $dataCells = [];
/**
* @param PurchaseOrderModel $purchase 采购单
* @param SupplierModel $supplier 供应商
* @param string $marketLabel 市场名(空市场已转为「未设置」)
* @param array<int, array<string, mixed>> $productRows 商品行(品名/汇总数量/各门店数量)
* @param array<int, string> $stores 门店列(门店ID => 名称)
* @param string $sheetTitle 工作表名
*/
public function __construct( public function __construct(
private readonly PurchaseOrderModel $purchase, private readonly PurchaseOrderModel $purchase,
private readonly SupplierModel $supplier, private readonly SupplierModel $supplier,
private readonly string $marketLabel,
private readonly array $productRows,
private readonly array $stores,
private readonly string $sheetTitle, private readonly string $sheetTitle,
) { ) {
} }
/** /**
* 导出行:标题/空行/列头/明细/合计 * 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计
*/ */
public function collection(): Collection public function collection(): Collection
{ {
@@ -42,48 +61,52 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
return $this->rows; return $this->rows;
} }
$items = app(PurchaseItemService::class)->supplierRows($this->purchase->id, $this->supplier->id); $storeIds = array_map('intval', array_keys($this->stores));
$rows = []; $rows = [];
$rowIndex = 0; $rowIndex = 0;
// 标题行 // 标题行
$rows[] = [$this->supplier->name . ' · 采购单 ' . $this->purchase->purchase_no]; $rows[] = [$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no];
$this->specialRows[++$rowIndex] = 'title'; $this->specialRows[++$rowIndex] = 'title';
// 空行 // 空行
$rows[] = ['']; $rows[] = [''];
$rowIndex++; $rowIndex++;
// 列头 // 列头:品名、汇总、市场、各门店明细
$rows[] = ['序号', '品名', '市场', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']; $rows[] = array_merge(['品名', '汇总', '市场'], array_values($this->stores));
$this->specialRows[++$rowIndex] = 'header'; $this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex; $this->headerRow = $rowIndex;
// 明细行 // 明细行(有数据的单元格记录到 dataCells:品名/汇总/门店数量)
$totalQuantity = 0; $totalQuantity = 0;
$totalWeight = '0'; $storeTotals = array_fill_keys($storeIds, 0);
$totalAmount = '0'; foreach ($this->productRows as $productRow) {
foreach (array_values($items) as $sort => $item) { $line = [
$rows[] = [ $productRow['product_name'],
$sort + 1, (int) $productRow['quantity'],
$item['product_name'], $this->marketLabel,
$item['market'],
$item['product_spec'],
$item['unit'],
(float) $item['cost_price'],
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
]; ];
$rowIndex++; $filled = [1, 2, 3];
$totalQuantity += (int) $item['quantity']; foreach ($storeIds as $position => $storeId) {
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3); $quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); $line[] = $quantity > 0 ? $quantity : '';
if ($quantity > 0) {
$filled[] = 4 + $position;
$storeTotals[$storeId] += $quantity;
}
}
$rows[] = $line;
$this->dataCells[++$rowIndex] = $filled;
$totalQuantity += (int) $productRow['quantity'];
} }
// 合计行 // 合计行
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount]; $rows[] = array_merge(
['合计', $totalQuantity, ''],
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
);
$this->specialRows[++$rowIndex] = 'summary'; $this->specialRows[++$rowIndex] = 'summary';
return $this->rows = collect($rows); return $this->rows = collect($rows);
@@ -95,17 +118,33 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
/** /**
* 标题/列头/合计加粗,冻结列头 * 标题/列头/合计加粗,有数据的单元格填充突出颜色,冻结列头
*/ */
public function styles(Worksheet $sheet): array public function styles(Worksheet $sheet): array
{ {
$this->collection(); $this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1)); $sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
$widths = [24, 10, 12];
foreach ($widths as $index => $width) { foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
} }
$storeCount = count($this->stores);
for ($i = 0; $i < $storeCount; $i++) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(12);
}
// 有数据的单元格:填充突出颜色并加粗(含品名/汇总/门店数量)
foreach ($this->dataCells as $rowIndex => $columns) {
foreach ($columns as $columnIndex) {
$style = $sheet->getStyle(Coordinate::stringFromColumnIndex($columnIndex) . $rowIndex);
$style->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB(self::DATA_FILL);
$style->getFont()->setBold(true);
}
}
$styles = []; $styles = [];
foreach ($this->specialRows as $row => $type) { foreach ($this->specialRows as $row => $type) {
@@ -7,6 +7,7 @@ use App\Http\Requests\Mini\MiniCartRequest;
use App\Models\CartModel; use App\Models\CartModel;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -207,6 +208,15 @@ class CartController extends BaseMiniController
return $this->success([], '已删除'); return $this->success([], '已删除');
} }
/**
* 购物车悬浮球汇总(轻量接口):种数/总数量/总金额,供任意页面刷新右下角悬浮球
*/
#[GetRoute('/cart/summary', authorize: true)]
public function summary(Request $request): JsonResponse
{
return $this->success(app(CartService::class)->summary($this->currentStore($request)));
}
/** /**
* 清空购物车(仅当前用户) * 清空购物车(仅当前用户)
*/ */
+5 -1
View File
@@ -5,19 +5,22 @@ namespace App\Http\Controllers\Mini;
use App\Models\HomeBannerModel; use App\Models\HomeBannerModel;
use App\Models\HomeNavModel; use App\Models\HomeNavModel;
use App\Models\HomePromoModel; use App\Models\HomePromoModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\AnnoRoute\Attribute\RequestAttribute;
/** /**
* 小程序首页配置(轮播图 + 宫格导航 + 促销推荐卡片,仅返回启用项) * 小程序首页配置(轮播图 + 宫格导航 + 促销推荐卡片,仅返回启用项)
* 登录门店附加 cart 购物车悬浮球汇总(未登录返回零值结构)
*/ */
#[RequestAttribute('/mini', 'mini', authGuard: 'users')] #[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class HomeController extends BaseMiniController class HomeController extends BaseMiniController
{ {
/** 首页配置聚合:banners / navs / promos,按 sort 升序 */ /** 首页配置聚合:banners / navs / promos,按 sort 升序 */
#[GetRoute('/home', authorize: false)] #[GetRoute('/home', authorize: false)]
public function index(): JsonResponse public function index(Request $request): JsonResponse
{ {
$banners = HomeBannerModel::query() $banners = HomeBannerModel::query()
->where('status', HomeBannerModel::STATUS_NORMAL) ->where('status', HomeBannerModel::STATUS_NORMAL)
@@ -41,6 +44,7 @@ class HomeController extends BaseMiniController
'banners' => $banners, 'banners' => $banners,
'navs' => $navs, 'navs' => $navs,
'promos' => $promos, 'promos' => $promos,
'cart' => app(CartService::class)->summary($this->optionalStore($request)),
]); ]);
} }
} }
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\PaymentModel;
use App\Services\OnlinePaymentService;
use App\Services\WangpuPayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Throwable;
/**
* 小程序在线支付(旺铺网关 JSAPI)
*
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
* → 小程序 wx.requestPayment 完成支付
* → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账)
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OnlinePaymentController extends BaseMiniController
{
public function __construct(
protected OnlinePaymentService $onlinePayment,
protected WangpuPayService $wangpu,
) {}
/**
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
* @throws Throwable
*/
#[PostRoute('/payment/online', authorize: true)]
public function create(Request $request): JsonResponse
{
$data = $request->validate([
'bill_ids' => 'required|array|min:1',
'bill_ids.*' => 'integer|distinct',
'code' => 'required|string|max:64',
'remark' => 'nullable|string|max:255',
], [
'bill_ids.required' => '请选择要付款的账单',
'bill_ids.min' => '请选择要付款的账单',
'code.required' => '微信登录凭证缺失,请重新进入小程序',
'remark.max' => '备注超过最大长度',
]);
$store = $this->currentStore($request);
[$payment, $payParams] = $this->onlinePayment->create(
$store,
array_map('intval', $data['bill_ids']),
(string) $data['code'],
(string) ($data['remark'] ?? ''),
);
return $this->success([
'id' => $payment->id,
'payment_no' => $payment->payment_no,
'amount' => $payment->amount,
'pay_params' => $payParams,
], '下单成功,请调起支付');
}
/**
* 查询支付结果:网关回调可能延迟/丢失,小程序完成支付后主动调用同步结账
* @throws Throwable
*/
#[GetRoute('/payment/online/{paymentNo}/query', authorize: true)]
public function query(string $paymentNo, Request $request): JsonResponse
{
$store = $this->currentStore($request);
$payment = PaymentModel::query()
->where('store_id', $store->id)
->where('payment_no', $paymentNo)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
$result = $this->onlinePayment->queryAndSettle($payment);
$payment = $result['payment'];
return $this->success([
'payment_no' => $payment->payment_no,
'status' => $payment->status,
'status_name' => PaymentModel::ONLINE_STATUS_NAMES[$payment->status] ?? '待支付',
'paid_at' => $payment->paid_at,
'trade_no' => $payment->trade_no,
], $payment->status === PaymentModel::STATUS_APPROVED ? '支付成功' : '支付结果确认中');
}
/**
* 旺铺支付结果后台通知(公开路由,验签后幂等结账)
*
* 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次;
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
*/
#[PostRoute('/payment/notify', authorize: false)]
public function notify(Request $request): JsonResponse
{
$params = $request->all();
if (! $this->wangpu->verifyNotifySign($params)) {
Log::warning('旺铺支付通知验签失败', ['params' => $params]);
return $this->notifyAck('01', '验签失败');
}
try {
$this->onlinePayment->settleByNotify($params);
} catch (Throwable $e) {
Log::error('旺铺支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
return $this->notifyAck('01', $e->getMessage());
}
return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功');
}
/**
* 通知应答报文(网关约定格式,timestampyyyyMMddHHmmssSSS
*/
protected function notifyAck(string $code, string $msg): JsonResponse
{
return response()->json([
'code' => $code,
'msg' => mb_substr($msg, 0, 100),
'timestamp' => now()->format('YmdHisv'),
]);
}
}
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Http\Requests\Mini\MiniOrderRequest; use App\Http\Requests\Mini\MiniOrderRequest;
use App\Models\BillModel;
use App\Models\CartModel;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
@@ -36,6 +38,26 @@ class OrderController extends BaseMiniController
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服'); throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
} }
// 回款周期校验:存在逾期未回款账单(未支付即拦截,含审核中)时禁止下单
// 周期 0 天=立即结清:任何未回款账单(含当天)都拦截;周期 N≥1:账单日 + N 天 < 今天(超过回款周期)才拦截
$cycleDays = (int) $store->payment_cycle_days;
$overdueQuery = BillModel::query()
->where('store_id', $store->id)
->where('status', BillModel::STATUS_UNPAID);
if ($cycleDays > 0) {
$overdueQuery->whereDate('bill_date', '<', now()->subDays($cycleDays)->toDateString());
}
$overdue = $overdueQuery
->selectRaw('COUNT(*) as aggregate_count, COALESCE(SUM(total_amount), 0) as aggregate_amount')
->first();
if ((int) $overdue->aggregate_count > 0) {
$amountText = bcadd((string) $overdue->aggregate_amount, '0', 2);
throw new RepositoryException($cycleDays > 0
? '您有 ' . (int) $overdue->aggregate_count . ' 笔账单已超过回款周期未回款(合计 ¥' . $amountText . '),请先结清后再下单'
: '您有 ' . (int) $overdue->aggregate_count . ' 笔账单未回款(合计 ¥' . $amountText . '),回款周期为当日结清,请先结清后再下单'
);
}
$items = $request->validated('items'); $items = $request->validated('items');
$remark = (string) ($request->validated('remark') ?? ''); $remark = (string) ($request->validated('remark') ?? '');
@@ -104,6 +126,12 @@ class OrderController extends BaseMiniController
} }
StoreOrderItemModel::insert($rows); StoreOrderItemModel::insert($rows);
// 下单成功后自动清空购物车中已下单的商品
CartModel::query()
->where('store_id', $store->id)
->whereIn('product_id', $productIds)
->delete();
return $order; return $order;
}); });
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductCategoryModel; use App\Models\ProductCategoryModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Services\CartService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\GetRoute;
@@ -25,7 +26,8 @@ class ProductController extends BaseMiniController
} }
/** /**
* 商品列表 * 商品列表(登录门店附 cart_id/cart_quantity 供列表直接加减购物车;
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
*/ */
#[GetRoute('/product/list', authorize: false)] #[GetRoute('/product/list', authorize: false)]
public function products(Request $request): JsonResponse public function products(Request $request): JsonResponse
@@ -50,22 +52,30 @@ class ProductController extends BaseMiniController
->orderBy('id') ->orderBy('id')
->paginate($pageSize); ->paginate($pageSize);
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100 // 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100与购物车行
$store = $this->optionalStore($request); $store = $this->optionalStore($request);
$level = ($store !== null && $store->level_id > 0) ? $store->level : null; $level = ($store !== null && $store->level_id > 0) ? $store->level : null;
$cartService = app(CartService::class);
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
$paginator->getCollection()->transform( $paginator->getCollection()->transform(
static function (ProductModel $product) use ($level): array { static function (ProductModel $product) use ($level, $cartRows): array {
$row = $product->toArray(); $row = $product->toArray();
// 实际价(按等级上浮比例换算;成本价不随序列化输出) // 实际价(按等级上浮比例换算;成本价不随序列化输出)
$row['price'] = $level !== null $row['price'] = $level !== null
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent) ? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
: null; : null;
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00'
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
return $row; return $row;
} }
); );
return $this->success($paginator->toArray()); $data = $paginator->toArray();
$data['cart'] = $cartService->summary($store);
return $this->success($data);
} }
/** /**
@@ -89,8 +99,12 @@ class ProductController extends BaseMiniController
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent); $price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
} }
$cartRows = $store !== null ? app(CartService::class)->cartRowMap($store->id) : [];
$data = $product->toArray(); $data = $product->toArray();
$data['price'] = $price; $data['price'] = $price;
$data['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
$data['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
return $this->success($data); return $this->success($data);
} }
@@ -52,6 +52,15 @@ class StoreOrderController extends BaseController
}); });
} }
// 按采购单号模糊搜索(关联采购单)
$purchaseNo = trim((string) ($params['purchase_no'] ?? ''));
if ($purchaseNo !== '') {
$keyword = '%' . str_replace('%', '\%', $purchaseNo) . '%';
$query->whereHas('purchase', static function ($purchaseQuery) use ($keyword) {
$purchaseQuery->where('purchase_no', 'like', $keyword);
});
}
$data = $this->buildSearch($params, $query) $data = $this->buildSearch($params, $query)
->orderBy('order_date', 'desc') ->orderBy('order_date', 'desc')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
@@ -105,7 +105,7 @@ class PurchaseOrderController extends BaseController
$quantity = 0; $quantity = 0;
// 采购总重量 // 采购总重量
$weight = '0'; $weight = '0';
// 采购总金额(Σ明细 amount,参考零售价按 金额÷数量 加权) // 采购总金额(Σ明细 amount,价按 金额÷数量 加权)
$amount = '0'; $amount = '0';
// 门店明细 // 门店明细
$cellsMap = []; $cellsMap = [];
@@ -24,6 +24,7 @@ class PaymentController extends BaseController
protected array $searchField = [ protected array $searchField = [
'store_id' => '=', 'store_id' => '=',
'status' => '=', 'status' => '=',
'pay_type' => '=',
'pay_method' => '=', 'pay_method' => '=',
'payment_no' => 'like', 'payment_no' => 'like',
]; ];
@@ -26,6 +26,7 @@ class ProductFormRequest extends BaseFormRequest
'name' => 'required|string|max:100', 'name' => 'required|string|max:100',
'spec' => 'nullable|string|max:100', 'spec' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:20', 'unit' => 'nullable|string|max:20',
'price_unit' => 'nullable|string|max:20',
'image_ids' => 'nullable|array|max:255', 'image_ids' => 'nullable|array|max:255',
'image_ids.*' => ['integer', new Exists(SysFileModel::class, 'id')], 'image_ids.*' => ['integer', new Exists(SysFileModel::class, 'id')],
'content' => 'nullable|string', 'content' => 'nullable|string',
@@ -5,7 +5,7 @@ namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest; use Modules\Common\Http\Requests\BaseFormRequest;
/** /**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改; * 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后/备注,金额由系统汇总不可修改;
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额) * 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额)
*/ */
class PurchaseBillGenerateRequest extends BaseFormRequest class PurchaseBillGenerateRequest extends BaseFormRequest
@@ -20,6 +20,8 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores.*.delivery_fee' => 'required|numeric|min:0', 'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer', 'stores.*.box_num' => 'required|integer',
'stores.*.tray_num' => 'required|integer', 'stores.*.tray_num' => 'required|integer',
'stores.*.after_sale' => 'nullable|string|max:255',
'stores.*.remark' => 'nullable|string|max:255',
]; ];
} }
@@ -39,6 +41,8 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)', 'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.tray_num.required' => '周转托盘数量不能为空', 'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)', 'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.after_sale.max' => '售后说明最长 255 个字符',
'stores.*.remark.max' => '备注最长 255 个字符',
]; ];
} }
} }
+1
View File
@@ -75,6 +75,7 @@ class BillModel extends Model
'paid_operator_id', 'paid_operator_id',
'operator_id', 'operator_id',
'remark', 'remark',
'after_sale',
]; ];
protected $casts = [ protected $casts = [
+50 -4
View File
@@ -11,31 +11,49 @@ use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemUser\Models\SysUserModel; use Modules\SystemUser\Models\SysUserModel;
/** /**
* 支付记录模型(小程序选择门店账单合并付款,提交汇款凭证;后台审核通过后关联账单批量置已支付 * 支付记录模型(小程序选择门店账单合并付款)
*
* 支付类型 pay_type
* - 1 线下凭证支付:门店提交汇款凭证,后台审核通过后关联账单批量置已支付
* - 2 旺铺在线支付:调起微信/支付宝在线支付,网关回调(或主动查询)确认后自动结账
*/ */
class PaymentModel extends Model class PaymentModel extends Model
{ {
use HasFactory; use HasFactory;
/** 支付类型:线下凭证支付 */
public const int TYPE_OFFLINE = 1;
/** 支付类型:旺铺在线支付 */
public const int TYPE_ONLINE = 2;
/** 支付类型中文名 */
public const array TYPE_NAMES = [
self::TYPE_OFFLINE => '凭证支付',
self::TYPE_ONLINE => '在线支付',
];
/** 支付方式:微信 */ /** 支付方式:微信 */
public const int METHOD_WECHAT = 1; public const int METHOD_WECHAT = 1;
/** 支付方式:支付宝 */ /** 支付方式:支付宝 */
public const int METHOD_ALIPAY = 2; public const int METHOD_ALIPAY = 2;
/** 支付方式:对公汇款(银行卡) */ /** 支付方式:对公汇款(银行卡) */
public const int METHOD_BANK = 3; public const int METHOD_BANK = 3;
/** 支付方式:旺铺在线支付(微信/支付宝小程序 JSAPI) */
public const int METHOD_WANGPU = 4;
/** 支付方式中文名 */ /** 支付方式中文名 */
public const array METHOD_NAMES = [ public const array METHOD_NAMES = [
self::METHOD_WECHAT => '微信支付', self::METHOD_WECHAT => '微信支付',
self::METHOD_ALIPAY => '支付宝', self::METHOD_ALIPAY => '支付宝',
self::METHOD_BANK => '对公汇款', self::METHOD_BANK => '对公汇款',
self::METHOD_WANGPU => '旺铺支付',
]; ];
/** 状态:待审核 */ /** 状态:待审核(线下凭证)/ 待支付(在线支付) */
public const int STATUS_PENDING = 0; public const int STATUS_PENDING = 0;
/** 状态:已通过 */ /** 状态:已通过(线下凭证)/ 支付成功(在线支付) */
public const int STATUS_APPROVED = 1; public const int STATUS_APPROVED = 1;
/** 状态:已拒绝 */ /** 状态:已拒绝(线下凭证)/ 支付失败(在线支付) */
public const int STATUS_REJECTED = 2; public const int STATUS_REJECTED = 2;
/** 状态中文名 */ /** 状态中文名 */
@@ -45,6 +63,13 @@ class PaymentModel extends Model
self::STATUS_REJECTED => '已拒绝', self::STATUS_REJECTED => '已拒绝',
]; ];
/** 在线支付状态中文名(pay_type=2 时使用) */
public const array ONLINE_STATUS_NAMES = [
self::STATUS_PENDING => '待支付',
self::STATUS_APPROVED => '支付成功',
self::STATUS_REJECTED => '支付失败',
];
protected $table = 'payment'; protected $table = 'payment';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
@@ -52,9 +77,15 @@ class PaymentModel extends Model
'payment_no', 'payment_no',
'store_id', 'store_id',
'amount', 'amount',
'pay_type',
'pay_method', 'pay_method',
'voucher_ids', 'voucher_ids',
'status', 'status',
'order_id',
'trade_no',
'openid',
'paid_at',
'pay_params',
'remark', 'remark',
'audited_at', 'audited_at',
'auditor_id', 'auditor_id',
@@ -64,13 +95,28 @@ class PaymentModel extends Model
protected $casts = [ protected $casts = [
'store_id' => 'integer', 'store_id' => 'integer',
'amount' => 'decimal:2', 'amount' => 'decimal:2',
'pay_type' => 'integer',
'pay_method' => 'integer', 'pay_method' => 'integer',
'status' => 'integer', 'status' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
'audited_at' => 'datetime:Y-m-d H:i:s', 'audited_at' => 'datetime:Y-m-d H:i:s',
'auditor_id' => 'integer', 'auditor_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
/**
* 旺铺下单返回的调起支付参数(JSON 字符串 ↔ 数组)
*
* @return Attribute<array<string, mixed>, string>
*/
public function payParams(): Attribute
{
return Attribute::make(
get: fn ($value) => $value === '' || $value === null ? [] : (json_decode((string) $value, true) ?: []),
set: fn ($value) => is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : $value,
);
}
/** /**
* 汇款凭证图片ID(逗号分隔字符串 ↔ 数组) * 汇款凭证图片ID(逗号分隔字符串 ↔ 数组)
*/ */
+2 -1
View File
@@ -31,6 +31,7 @@ class ProductModel extends Model
'name', 'name',
'spec', 'spec',
'unit', 'unit',
'price_unit',
'image_ids', 'image_ids',
'content', 'content',
'sort', 'sort',
@@ -67,7 +68,7 @@ class ProductModel extends Model
public function imageIds(): Attribute public function imageIds(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn ($value) => explode(',', $value), get: fn ($value) => $value ? explode(',', $value) : [],
set: fn ($value) => is_array($value) ? implode(',', $value) : $value, set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
); );
} }
+1
View File
@@ -37,6 +37,7 @@ class StoreModel extends Authenticatable
'phone', 'phone',
'address', 'address',
'payment_cycle_days', 'payment_cycle_days',
'openid',
'status', 'status',
'remark', 'remark',
]; ];
+3 -1
View File
@@ -31,7 +31,7 @@ readonly class BillGenerateService
/** /**
* @param PurchaseOrderModel $purchase 已完成采购单 * @param PurchaseOrderModel $purchase 已完成采购单
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量 * @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int, after_sale?: string, remark?: string}> $stores 按门店提交的配送费/周转筐/托盘数量与售后/备注
* @param int $operatorId 生成人(后台系统用户ID) * @param int $operatorId 生成人(后台系统用户ID)
* @return BillModel[] 生成的账单列表 * @return BillModel[] 生成的账单列表
* @throws Throwable * @throws Throwable
@@ -111,6 +111,8 @@ readonly class BillGenerateService
'added_amount' => $addedAmount, 'added_amount' => $addedAmount,
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'operator_id' => $operatorId, 'operator_id' => $operatorId,
'after_sale' => (string) ($row['after_sale'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
]); ]);
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」 // 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\CartModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\StoreModel;
/**
* 购物车共享服务:商品列表/详情附加购物车数量、悬浮球汇总(种数/总数量/总金额)
*
* 汇总口径(与 Mini/CartController::index 一致,勿偏离):
* - total_count = 购物车全部行数(含已下架等不可购项)
* - total_quantity / total_amount = 仅统计可购项(商品在上架且门店已设等级),
* 金额 = Σ 数量 × 等级上浮价(CustomerLevelModel::calcLevelPrice
*/
class CartService
{
/**
* 门店购物车行映射:product_id => ['id' => 购物车行ID, 'quantity' => 数量字符串]
* (商品列表/详情附加 cart_id/cart_quantity 用;行ID供直接加减/删除操作)
*
* @return array<int, array{id: int, quantity: string}>
*/
public function cartRowMap(int $storeId): array
{
return CartModel::query()
->where('store_id', $storeId)
->get(['id', 'product_id', 'quantity'])
->keyBy('product_id')
->map(static fn (CartModel $row): array => [
'id' => (int) $row->id,
'quantity' => (string) $row->quantity,
])
->all();
}
/**
* 购物车悬浮球汇总:种数/总数量/总金额(未登录或空购物车返回零值结构)
*
* @return array{total_count: int, total_quantity: string, total_amount: string}
*/
public function summary(?StoreModel $store): array
{
$empty = ['total_count' => 0, 'total_quantity' => '0.00', 'total_amount' => '0.00'];
if ($store === null) {
return $empty;
}
$rows = CartModel::query()
->where('store_id', $store->id)
->get(['id', 'product_id', 'quantity']);
if ($rows->isEmpty()) {
return $empty;
}
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $rows->pluck('product_id')->all())
->get(['id', 'cost_price'])
->keyBy('id');
$level = $store->level_id > 0 ? $store->level : null;
$totalQuantity = '0.00';
$totalAmount = '0.00';
foreach ($rows as $row) {
$product = $products->get($row->product_id);
if ($product === null || $level === null) {
continue;
}
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
$totalQuantity = bcadd($totalQuantity, (string) $row->quantity, 2);
$totalAmount = bcadd($totalAmount, bcmul($price, (string) $row->quantity, 2), 2);
}
return [
'total_count' => $rows->count(),
'total_quantity' => $totalQuantity,
'total_amount' => $totalAmount,
];
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\StoreModel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 在线支付编排服务(旺铺网关)
*
* 链路:门店小程序选择账单合并付款
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2
* → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
*
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
*/
class OnlinePaymentService
{
public function __construct(
protected BillNumberService $billNumber,
protected WangpuPayService $wangpu,
protected WechatMiniService $wechat,
) {}
/**
* 发起在线支付
*
* @param array<int, int> $billIds 合并付款的账单ID
* @param string $code 小程序 wx.login() 返回的登录凭证
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
* @throws Throwable
*/
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
{
// 换取付款人 openid 并绑定到门店(下次可直接复用)
$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) {
$bills = BillModel::query()
->where('store_id', $store->id)
->whereIn('id', $billIds)
->lockForUpdate()
->get();
if ($bills->count() !== count($billIds)) {
throw new RepositoryException('包含不属于本店的账单,请刷新后重试');
}
foreach ($bills as $bill) {
if ($bill->status === BillModel::STATUS_PAID) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 已支付,请刷新后重试');
}
if ((int) $bill->payment_id !== 0) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 正在支付中,请勿重复提交');
}
}
$amount = $bills->reduce(
static fn (string $carry, BillModel $bill): string => bcadd($carry, (string) $bill->total_amount, 2),
'0'
);
if (bccomp($amount, '0', 2) <= 0) {
throw new RepositoryException('支付金额必须大于 0');
}
$payment = PaymentModel::create([
'payment_no' => $this->billNumber->make('ZF'),
'store_id' => $store->id,
'amount' => $amount,
'pay_type' => PaymentModel::TYPE_ONLINE,
'pay_method' => PaymentModel::METHOD_WANGPU,
'voucher_ids' => '',
'status' => PaymentModel::STATUS_PENDING,
'openid' => $openid,
'remark' => $remark,
]);
// 锁定账单到本支付记录(支付失败/取消后释放,可重新付款)
BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]);
return [$payment, $bills];
});
try {
$gatewayData = $this->wangpu->createOrder([
'mer_order_id' => $payment->payment_no,
'order_amt' => (string) $payment->amount,
'open_id' => $openid,
'sub_appid' => $this->subAppid(),
'order_title' => '账单合并付款-' . $payment->payment_no,
'notifyurl' => $this->wangpu->notifyUrl(),
]);
} catch (Throwable $e) {
// 网关下单失败:整笔作废并释放账单,门店可重新发起
$this->discard($payment, $e->getMessage());
throw $e;
}
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
$payment->pay_params = $gatewayData;
$payment->save();
return [$payment, $gatewayData];
}
/**
* 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
*
* @param array<string, mixed> $params 通知报文(已验签)
* @return bool 本次是否执行了结账(false = 重复通知)
* @throws Throwable
*/
public function settleByNotify(array $params): bool
{
$merOrderId = (string) ($params['mer_order_id'] ?? '');
$payment = PaymentModel::query()
->where('payment_no', $merOrderId)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在:' . $merOrderId);
}
if ((int) ($params['order_status'] ?? -1) !== WangpuPayService::ORDER_STATUS_PAID) {
throw new RepositoryException('订单未支付成功(order_status=' . ($params['order_status'] ?? '空') . '');
}
return $this->settle(
$payment,
(string) ($params['trade_no'] ?? ''),
(string) ($params['order_id'] ?? ''),
(string) ($params['trade_time'] ?? ''),
(string) ($params['order_amt'] ?? '0'),
);
}
/**
* 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
*
* @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态
* @throws Throwable
*/
public function queryAndSettle(PaymentModel $payment): array
{
if ($payment->status === PaymentModel::STATUS_APPROVED) {
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
}
$data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id);
$orderStatus = (int) ($data['order_status'] ?? -1);
if ($orderStatus === WangpuPayService::ORDER_STATUS_PAID) {
$this->settle(
$payment,
(string) ($data['trade_no'] ?? ''),
(string) ($data['order_id'] ?? ''),
(string) ($data['trade_time'] ?? ''),
(string) ($data['order_amt'] ?? '0'),
);
}
return ['payment' => $payment->fresh(), 'order_status' => $orderStatus];
}
/**
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
*
* @return bool 本次是否执行了结账(false = 已结账,直接吞掉重复通知)
* @throws Throwable
*/
public function settle(PaymentModel $payment, string $tradeNo, string $orderId, string $tradeTime, string $paidAmount): bool
{
$settled = DB::transaction(function () use ($payment, $tradeNo, $orderId, $tradeTime, $paidAmount) {
$payment = PaymentModel::query()->lockForUpdate()->find($payment->id);
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
if ($payment->status === PaymentModel::STATUS_APPROVED) {
return false; // 幂等:重复通知/查询直接成功
}
if ($payment->pay_type !== PaymentModel::TYPE_ONLINE || $payment->status !== PaymentModel::STATUS_PENDING) {
throw new RepositoryException('支付记录状态异常,无法结账');
}
// 金额一致性校验,防通知篡改
if (bccomp($paidAmount, (string) $payment->amount, 2) !== 0) {
throw new RepositoryException('支付金额与订单金额不一致(通知 ' . $paidAmount . ' / 订单 ' . $payment->amount . '');
}
$bills = $payment->bills()->lockForUpdate()->get();
// 任一账单已通过其他方式收款(如线下登记)则中止,避免重复收款,需人工核实
$paid = $bills->where('status', BillModel::STATUS_PAID);
if ($paid->isNotEmpty()) {
throw new RepositoryException('账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已通过其他方式收款,请人工核实');
}
$paidAt = $this->parseTradeTime($tradeTime);
BillModel::query()->whereIn('id', $bills->pluck('id'))->update([
'status' => BillModel::STATUS_PAID,
'paid_at' => $paidAt,
'paid_operator_id' => 0,
'pay_remark' => PaymentModel::METHOD_NAMES[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $payment->payment_no . '',
]);
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
$store = StoreModel::query()->lockForUpdate()->find($payment->store_id);
if ($store !== null) {
$amount = '0';
foreach ($bills as $bill) {
$amount = bcadd($amount, (string) $bill->product_amount, 2);
}
$store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, $amount, 2);
$store->save();
}
$payment->status = PaymentModel::STATUS_APPROVED;
$payment->trade_no = $tradeNo;
if ($orderId !== '') {
$payment->order_id = $orderId;
}
$payment->paid_at = $paidAt;
$payment->save();
return true;
});
if ($settled) {
$this->notifyStore($payment->fresh());
}
return $settled;
}
/**
* 网关下单失败后作废支付单:置失败并释放账单(保留记录便于排查)
*/
protected function discard(PaymentModel $payment, string $reason): void
{
try {
DB::transaction(function () use ($payment, $reason) {
BillModel::query()
->where('payment_id', $payment->id)
->where('status', BillModel::STATUS_UNPAID)
->update(['payment_id' => 0]);
PaymentModel::query()->where('id', $payment->id)->update([
'status' => PaymentModel::STATUS_REJECTED,
'audit_remark' => mb_substr('网关下单失败:' . $reason, 0, 255),
]);
});
} catch (Throwable $e) {
Log::error('在线支付作废失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 结账成功后通知门店
*/
protected function notifyStore(PaymentModel $payment): void
{
try {
$billsCount = $payment->bills()->count();
NoticeModel::create([
'store_id' => $payment->store_id,
'type' => NoticeModel::TYPE_SYSTEM,
'title' => '账单支付成功',
'content' => mb_substr("您的 {$billsCount} 张账单已通过在线支付完成付款,金额 {$payment->amount} 元(支付单号 {$payment->payment_no}", 0, 500),
'data' => ['payment_id' => $payment->id],
'is_read' => NoticeModel::UNREAD,
]);
} catch (Throwable $e) {
Log::warning('支付成功通知门店失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 网关交易时间解析(格式 yyyy-MM-dd HH:mm:ss,异常回退当前时间)
*/
protected function parseTradeTime(string $tradeTime): Carbon
{
try {
return $tradeTime !== '' ? Carbon::parse($tradeTime) : now();
} catch (Throwable) {
return now();
}
}
/**
* 下单微信子 appid(默认取小程序 appid
*/
protected function subAppid(): string
{
$subAppid = (string) site_config('pay.wangpu_sub_appid', '');
if ($subAppid === '') {
$subAppid = (string) config('services.wangpu.sub_appid', '');
}
if ($subAppid === '') {
$subAppid = (string) config('services.wechat.mini.appid', '');
}
return trim($subAppid);
}
}
+55
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -138,6 +139,60 @@ class PurchaseItemService
return $this->sortRows($rows); return $this->sortRows($rows);
} }
/**
* 供应商采购明细矩阵(导出用):该供应商在采购单内的商品行按市场分组,
* 行附「汇总数量 + 各门店数量」;门店列=本采购单内有该供应商明细的门店(按ID排序)
*
* @return array{
* stores: array<int, string>,
* markets: array<string, array<int, array<string, mixed>>>
* }
*/
public function supplierMarketMatrix(int $purchaseId, int $supplierId): array
{
$items = $this->purchaseItems($purchaseId)
->where('supplier_id', $supplierId)
->sortBy('id');
$storeIds = $items->pluck('store_id')
->map(static fn ($id): int => (int) $id)
->unique()->sort()->values()->all();
$stores = StoreModel::withTrashed()
->whereIn('id', $storeIds)
->orderBy('id')
->pluck('name', 'id')
->toArray();
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$quantity = 0;
$storeQuantities = array_fill_keys(array_map('intval', array_keys($stores)), 0);
foreach ($group as $item) {
$quantity += (int) $item->quantity;
$storeQuantities[(int) $item->store_id] += (int) $item->quantity;
}
$rows[] = [
'product_id' => (int) $productId,
'product_name' => $first->product_name,
'market' => (string) data_get($first, 'market', ''),
'quantity' => $quantity,
'store_quantities' => $storeQuantities,
'category_sort' => (int) data_get($first, 'category_sort', 9999),
'product_sort' => (int) data_get($first, 'product_sort', 9999),
];
}
// 排序后按市场分组(组间/组内均保持 分类sort → 商品sort 顺序)
$markets = [];
foreach ($this->sortRows($rows) as $row) {
$markets[$row['market']][] = $row;
}
return ['stores' => $stores, 'markets' => $markets];
}
/** /**
* 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键) * 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键)
* *
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签)
*
* 加签规则(请求与回调一致):
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
*
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.phpenv
*/
class WangpuPayService
{
/** 网关应答成功码 */
public const string CODE_SUCCESS = '0000';
/** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */
public const string NOTIFY_ACK_OK = '00';
/** 订单状态:支付成功(交易查询 / 后台通知的 order_status */
public const int ORDER_STATUS_PAID = 1;
/**
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码)
*
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
* @throws RepositoryException 网关应答失败
*/
public function createOrder(array $params): array
{
return $this->request('/industrial/payment/order', array_merge([
'mer_code' => $this->config('mer_code'),
'term_code' => $this->config('term_code'),
'payway_code' => $this->config('payway_code'),
], $params));
}
/**
* 交易查询(单笔订单支付状态同步)
*
* @param string $merOrderId 商户唯一订单号(本系统 payment_no
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
* @return array<string, mixed> 网关 data 节点
* @throws RepositoryException 网关应答失败
*/
public function queryOrder(string $merOrderId, string $orderId = ''): array
{
$params = ['mer_order_id' => $merOrderId];
if ($orderId !== '') {
$params['order_id'] = $orderId;
}
return $this->request('/industrial/query/order', $params);
}
/**
* 校验后台通知签名(支付成功/退款成功通知通用)
*
* @param array<string, mixed> $params 通知报文全量参数(含 sign)
*/
public function verifyNotifySign(array $params): bool
{
$sign = (string) ($params['sign'] ?? '');
if ($sign === '') {
return false;
}
return hash_equals($this->sign($params), strtoupper($sign));
}
/**
* MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接,
* 末尾拼接 &key={SignKey} 后 MD5 转大写
*
* @param array<string, mixed> $params
*/
public function sign(array $params): string
{
unset($params['sign']);
$pairs = [];
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$pairs[(string) $key] = (string) $key . '=' . (string) $value;
}
ksort($pairs, SORT_STRING);
$signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key');
return strtoupper(md5($signStr));
}
/**
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
}
/**
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
*
* @param array<string, mixed> $params 接口业务参数
* @return array<string, mixed> 应答 data 节点
* @throws RepositoryException 通讯失败或应答码非成功
*/
protected function request(string $path, array $params): array
{
$baseUrl = rtrim($this->config('base_url'), '/');
if ($baseUrl === '') {
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
}
$body = array_merge([
'organiz_no' => $this->config('organiz_no'),
'mer_no' => $this->config('mer_no'),
], $params);
// 空值参数不下送(与网关签名口径一致)
$body = array_filter($body, static fn ($value): bool => $value !== null && $value !== '');
$body['sign'] = $this->sign($body);
$response = Http::asForm()->timeout(15)->post($baseUrl . $path, $body);
$result = $response->json();
if (! is_array($result)) {
Log::error('旺铺网关应答异常', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
throw new RepositoryException('支付网关通讯异常,请稍后重试');
}
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
Log::warning('旺铺网关应答失败', ['path' => $path, 'response' => $result]);
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
}
$data = $result['data'] ?? [];
return is_array($data) ? $data : [];
}
/**
* 读取网关配置:后台站点配置优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('pay.wangpu_' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wangpu.' . $key, '');
}
return trim((string) $value);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 微信小程序服务(wx.login 登录凭证校验 code2session
*
* 在线支付前置:用小程序 wx.login() 返回的 code 换取付款人 openid
* 作为旺铺统一下单的 open_id(服务商模式下的微信用户标识)。
*/
class WechatMiniService
{
/**
* wx.login 的 code 换 openid
*
* @return string 用户 openid
* @throws RepositoryException 未配置小程序或凭证校验失败
*/
public function code2session(string $code): string
{
$appid = trim((string) config('services.wechat.mini.appid'));
$secret = trim((string) config('services.wechat.mini.secret'));
if ($appid === '' || $secret === '') {
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
}
$result = Http::timeout(10)->get('https://api.weixin.qq.com/sns/jscode2session', [
'appid' => $appid,
'secret' => $secret,
'js_code' => $code,
'grant_type' => 'authorization_code',
])->json();
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
if ($openid === '') {
Log::warning('微信 code2session 失败', ['response' => $result]);
throw new RepositoryException('微信登录凭证校验失败:' . ($result['errmsg'] ?? '请重新进入小程序'));
}
return $openid;
}
}
+15
View File
@@ -52,4 +52,19 @@ return [
'secret' => env('WECHAT_MINI_SECRET', ''), 'secret' => env('WECHAT_MINI_SECRET', ''),
], ],
], ],
/*
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置
*/
'wangpu' => [
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
'organiz_no' => env('WANGPU_ORGANIZ_NO', ''), // 合作机构渠道号
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 KeyMD5 加签)
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
],
]; ];
@@ -42,6 +42,7 @@ return new class extends Migration
$table->string('phone', 20)->default('')->comment('联系电话'); $table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址'); $table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)'); $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)'); $table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->nullable()->default('')->comment('备注'); $table->string('remark', 255)->nullable()->default('')->comment('备注');
@@ -36,6 +36,7 @@ return new class extends Migration
$table->string('name', 100)->comment('品名'); $table->string('name', 100)->comment('品名');
$table->string('spec', 100)->default('')->comment('规格/包规'); $table->string('spec', 100)->default('')->comment('规格/包规');
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)'); $table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
$table->string('price_unit', 20)->nullable()->default('元/斤')->comment('单价单位(如:元/斤、元/箱)');
$table->string('image_ids', 255)->default('')->comment('商品图片'); $table->string('image_ids', 255)->default('')->comment('商品图片');
$table->text('content')->comment('商品图文详情'); $table->text('content')->comment('商品图文详情');
$table->integer('sort')->default(0)->comment('排序'); $table->integer('sort')->default(0)->comment('排序');
@@ -35,7 +35,8 @@ return new class extends Migration
$table->string('pay_remark', 255)->default('')->comment('付款备注(线下收款信息)'); $table->string('pay_remark', 255)->default('')->comment('付款备注(线下收款信息)');
$table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)'); $table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)');
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID'); $table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID');
$table->string('remark', 255)->nullable()->default('')->comment('备注'); $table->string('remark', 255)->nullable()->default('')->comment('备注(生成账单时按门店填写)');
$table->string('after_sale', 255)->nullable()->default('')->comment('售后说明(生成账单时按门店填写)');
$table->timestamps(); $table->timestamps();
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique'); $table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
$table->index(['store_id', 'bill_date'], 'bill_store_date_index'); $table->index(['store_id', 'bill_date'], 'bill_store_date_index');
@@ -15,19 +15,26 @@ return new class extends Migration
if (! Schema::hasTable('payment')) { if (! Schema::hasTable('payment')) {
Schema::create('payment', function (Blueprint $table) { Schema::create('payment', function (Blueprint $table) {
$table->increments('id')->comment('支付记录ID'); $table->increments('id')->comment('支付记录ID');
$table->string('payment_no', 32)->unique()->comment('支付单号'); $table->string('payment_no', 32)->unique()->comment('支付单号(在线支付时作为商户订单号 mer_order_id 上送网关)');
$table->integer('store_id')->comment('门店ID(提交门店即支付人)'); $table->integer('store_id')->comment('门店ID(提交门店即支付人)');
$table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)'); $table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)');
$table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款'); $table->integer('pay_type')->default(1)->comment('支付类型(1线下凭证支付 2旺铺在线支付');
$table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔'); $table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款 4旺铺在线支付');
$table->integer('status')->default(0)->comment('状态(0待审核 1已通过 2已拒绝'); $table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔,线下凭证支付');
$table->integer('status')->default(0)->comment('状态(0待审核/待支付 1已通过/支付成功 2已拒绝/支付失败)');
$table->string('order_id', 64)->default('')->comment('旺铺平台订单号(网关返回)');
$table->string('trade_no', 64)->default('')->comment('通道交易流水号(支付成功返回)');
$table->string('openid', 64)->default('')->comment('付款人微信 openid(小程序在线支付)');
$table->timestamp('paid_at')->nullable()->comment('支付成功时间(网关交易时间)');
$table->text('pay_params')->nullable()->comment('旺铺下单返回的调起支付参数(JSON 快照)');
$table->string('remark', 255)->default('')->comment('门店备注'); $table->string('remark', 255)->default('')->comment('门店备注');
$table->timestamp('audited_at')->nullable()->comment('审核时间'); $table->timestamp('audited_at')->nullable()->comment('审核时间');
$table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID'); $table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID');
$table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)'); $table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)');
$table->timestamps(); $table->timestamps();
$table->index(['store_id', 'status'], 'payment_store_status_index'); $table->index(['store_id', 'status'], 'payment_store_status_index');
$table->comment('支付记录表(小程序合并付款,后台审核汇款凭证)'); $table->index(['pay_type', 'status'], 'payment_type_status_index');
$table->comment('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
}); });
} }
} }
+8
View File
@@ -29,6 +29,14 @@ class SysDataSeeder extends Seeder
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], ['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], ['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date], ['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
['id' => 12, 'group_id' => 4, 'key' => 'wangpu_base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
['id' => 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_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 KeySignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appidsub_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],
]); ]);
// 字典类型初始数据 // 字典类型初始数据
DB::table('sys_dict')->insert([ DB::table('sys_dict')->insert([
+235
View File
@@ -0,0 +1,235 @@
# 小程序接口文档:在线支付(旺铺网关 JSAPI)
> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单,
> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账,
> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。
>
> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。
## 整体流程
```
小程序 后端 旺铺网关 微信
│ wx.login → code │ │ │
│ POST /mini/payment/online (bill_ids, code) │ │
│────────────────────▶│ code2session 换 openid │──────────────────▶│
│ │ 创建支付单+锁定账单 │ │
│ │ 统一下单(mer_order_id=支付单号) ──────────▶│
│ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│
│◀────────────────────│ │ │
│ wx.requestPayment(pay_params) ─────────────────────────────────▶│
│ │ POST /mini/payment/notify(支付成功通知) │
│ │◀───────────────────────│ │
│ │ 验签 → 幂等结账 → 应答 {"code":"00"} │
│ GET .../query 主动同步(兜底) │ │
│────────────────────▶│ 交易查询 → 已支付则结账 │ │
```
## 通用约定
| 项 | 值 |
|---|---|
| 鉴权 | 门店 token`Authorization: Bearer <token>`(登录见 `/mini/auth/login` |
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
| 金额单位 | 元,字符串/数字两位小数(如 `150.50` |
---
## 1. 发起在线支付(合并账单下单)
| 项 | 值 |
|---|---|
| 请求方式 | `POST` |
| 路径 | `/mini/payment/online` |
| 鉴权 | 门店 token |
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 |
| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) |
| `remark` | string | 否 | 付款备注,最长 255 字 |
### 校验规则(失败均返回 `success:false`
- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定;
- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号;
- 合计金额必须大于 0
- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单;
- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。
### 响应示例
```json
{
"success": true,
"msg": "下单成功,请调起支付",
"data": {
"id": 12,
"payment_no": "ZF202608270001",
"amount": "150.50",
"pay_params": {
"order_id": "202608271201444525348059",
"tradeNo": "2021082722001407831438768160",
"user_openid": "oXxx123"
}
}
}
```
### 字段说明
| 字段 | 说明 |
|---|---|
| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 |
| `amount` | 应付金额(= 所选账单总额合计,元) |
| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 |
### 小程序端调用示例
```js
// 1. 获取登录凭证
const { code } = await wx.login();
// 2. 后端下单
const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code });
const { payment_no, pay_params } = res.data;
// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准)
await wx.requestPayment({
timeStamp: pay_params.timeStamp,
nonceStr: pay_params.nonceStr,
package: pay_params.package,
signType: pay_params.signType || 'RSA',
paySign: pay_params.paySign,
});
// 4. 支付完成后主动同步结果(回调兜底)
const q = await request.get(`/mini/payment/online/${payment_no}/query`);
// q.data.status === 1 → 支付成功,刷新账单列表
```
---
## 2. 查询支付结果(主动同步)
| 项 | 值 |
|---|---|
| 请求方式 | `GET` |
| 路径 | `/mini/payment/online/{payment_no}/query` |
| 鉴权 | 门店 token(仅可查询本店支付单) |
### 路径参数
| 参数 | 说明 |
|---|---|
| `payment_no` | 下单返回的支付单号(如 `ZF202608270001` |
### 行为说明
- 本地已是「支付成功」→ 直接返回,不再请求网关;
- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑);
- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次;
- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。
### 响应示例
```json
{
"success": true,
"msg": "支付成功",
"data": {
"payment_no": "ZF202608270001",
"status": 1,
"status_name": "支付成功",
"paid_at": "2026-08-27 10:00:00",
"trade_no": "2021082722001407831438768160"
}
}
```
### `status` 取值
| 值 | 含义 |
|---|---|
| `0` | 待支付(网关未确认) |
| `1` | 支付成功(账单已置已支付) |
| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) |
---
## 3. 支付结果后台通知(服务端对接,无需小程序调用)
| 项 | 值 |
|---|---|
| 请求方式 | `POST`(表单) |
| 路径 | `/mini/payment/notify` |
| 鉴权 | 无(公开路由,以报文 `sign` 验签) |
| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify` |
### 处理逻辑
1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value``&` 拼接 + `&key=SignKey`MD5 大写);
2. 验签失败 → 应答 `{"code":"01"}`
3.`mer_order_id` 定位支付单,校验 `order_status=1``order_amt` 与支付单金额一致(防篡改);
4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店;
5. 重复通知直接应答成功,不重复结账。
### 应答报文(网关约定格式)
```json
{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" }
```
网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。
---
## 后台配置(上线前必配)
配置优先级:后台「系统设置 → 支付配置」> `.env`
### 后台支付配置(site_config `pay` 组,已内置配置项)
| 配置项 | 说明 |
|---|---|
| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) |
| `wangpu_organiz_no` | 合作机构渠道号 organiz_no |
| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) |
| `wangpu_mer_code` | 商户号 mer_code |
| `wangpu_term_code` | 终端号 term_code |
| `wangpu_sign_key` | 加签专用 KeySignKey |
| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid |
| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) |
### .env 备选配置
```dotenv
WECHAT_MINI_APPID= # 小程序 AppIDcode2session 必需)
WECHAT_MINI_SECRET= # 小程序 Secretcode2session 必需)
WANGPU_BASE_URL=
WANGPU_ORGANIZ_NO=
WANGPU_MER_NO=
WANGPU_MER_CODE=
WANGPU_TERM_CODE=
WANGPU_SIGN_KEY=
WANGPU_SUB_APPID=
WANGPU_PAYWAY_CODE=WECHAT_MINI
```
> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。
## 与线下凭证支付的关系
两种付款方式并存,同一张账单同一时刻只能处于一条支付链路:
| | 在线支付(本档) | 线下凭证支付 |
|---|---|---|
| 入口 | `POST /mini/payment/online` | `POST /mini/payment` |
| 支付单 `pay_type` | `2` | `1` |
| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 |
| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 |
| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 |
账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-BS9orhKm.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`首页轮播图`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页顶部轮播图;停用后不展示,排序越小越靠前;跳转链接为小程序页面路径,留空则点击不跳转。`})]}),(0,s.jsx)(a,{api:`/client/banner`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`标题`,dataIndex:`title`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入轮播图标题`}]},{title:`轮播图片`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传轮播图片`}],fieldProps:{action:`/client/banner/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:80,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/goods/detail?id=1`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.banner`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:640}})]});export{u as default}; import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-DOLiJ5rL.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`首页轮播图`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页顶部轮播图;停用后不展示,排序越小越靠前;跳转链接为小程序页面路径,留空则点击不跳转。`})]}),(0,s.jsx)(a,{api:`/client/banner`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`标题`,dataIndex:`title`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入轮播图标题`}]},{title:`轮播图片`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传轮播图片`}],fieldProps:{action:`/client/banner/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:80,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/goods/detail?id=1`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.banner`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:640}})]});export{u as default};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{m as i}from"./lodash-DzOfPy0f.js";import{t as a}from"./button-BILozH6U.js";import{l as o}from"./XinForm-BPDYdeax.js";import{t as s}from"./form-B3R3kIqp.js";import{t as c}from"./tag-DBV1bHre.js";import{t as l}from"./XinTable-BS9orhKm.js";import{n as u,t as d}from"./category-MmrJkdbs.js";var f=e(t(),1),p={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},m=n(),{Title:h,Text:g}=r,_=({form:e,tree:t,value:n,onChange:r})=>{let i=s.useWatch(`id`,e),a=s.useWatch(`children`,e),c=Array.isArray(a)&&a.length>0;return(0,m.jsx)(o,{value:n,onChange:r,treeData:(0,f.useMemo)(()=>{let e=(t,n)=>t.map(t=>({...t,disabled:n>=2||t.id===i||c&&n>=1,children:t.children?.length?e(t.children,n+1):t.children}));return[{id:0,name:`顶级分类`,children:e(t,1)}]},[t,i,c]),fieldNames:{label:`name`,value:`id`,children:`children`},placeholder:`默认顶级分类`,treeDefaultExpandAll:!0})};function v(e){let t=[],n=e=>{e.forEach(e=>{e.id!==void 0&&t.push(e.id),e.children?.length&&n(e.children)})};return n(e),t}var y=()=>{let[e,t]=(0,f.useState)([]),[n,r]=(0,f.useState)([]),[o,s]=(0,f.useState)([]);return(0,f.useEffect)(()=>{u().then(e=>s(e.data.data??[]))},[]),(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`mb-5`,children:[(0,m.jsx)(h,{level:3,children:`商品分类`}),(0,m.jsx)(g,{type:`secondary`,children:`多级分类(如蔬菜/水果/其他),采购单导出与对账筛选按分类归组;有子分类或挂载商品时不可删除。`})]}),(0,m.jsx)(l,{api:`/product/category`,columns:[{title:`分类名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入分类名称`}]},{title:`上级分类`,dataIndex:`parent_id`,hideInTable:!0,hideInSearch:!0,initialValue:0,fieldRender:e=>(0,m.jsx)(_,{form:e,tree:o})},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0},align:`center`},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,hideInSearch:!0,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=p[t.status??1];return(0,m.jsx)(c,{color:n?.color,children:n?.text})},align:`center`},{title:`创建时间`,dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`product.category`,handleRequest:async()=>{let e=(await d()).data.data??[];return r(v(e)),{data:e,total:e.length}},pagination:{pageSize:200},expandable:{expandedRowKeys:e,onExpandedRowsChange:e=>t([...e])},actionBarRender:r=>[r.add,(0,m.jsx)(a,{icon:(0,m.jsx)(i,{}),onClick:()=>t(e.length?[]:n),children:e.length?`全部收起`:`全部展开`}),r.keywordSearch],formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:640}})]})};export{y as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{m as i}from"./lodash-DzOfPy0f.js";import{t as a}from"./button-BILozH6U.js";import{l as o}from"./XinForm-BPDYdeax.js";import{t as s}from"./form-B3R3kIqp.js";import{t as c}from"./tag-DBV1bHre.js";import{t as l}from"./XinTable-DOLiJ5rL.js";import{n as u,t as d}from"./category-MmrJkdbs.js";var f=e(t(),1),p={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},m=n(),{Title:h,Text:g}=r,_=({form:e,tree:t,value:n,onChange:r})=>{let i=s.useWatch(`id`,e),a=s.useWatch(`children`,e),c=Array.isArray(a)&&a.length>0;return(0,m.jsx)(o,{value:n,onChange:r,treeData:(0,f.useMemo)(()=>{let e=(t,n)=>t.map(t=>({...t,disabled:n>=2||t.id===i||c&&n>=1,children:t.children?.length?e(t.children,n+1):t.children}));return[{id:0,name:`顶级分类`,children:e(t,1)}]},[t,i,c]),fieldNames:{label:`name`,value:`id`,children:`children`},placeholder:`默认顶级分类`,treeDefaultExpandAll:!0})};function v(e){let t=[],n=e=>{e.forEach(e=>{e.id!==void 0&&t.push(e.id),e.children?.length&&n(e.children)})};return n(e),t}var y=()=>{let[e,t]=(0,f.useState)([]),[n,r]=(0,f.useState)([]),[o,s]=(0,f.useState)([]);return(0,f.useEffect)(()=>{u().then(e=>s(e.data.data??[]))},[]),(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`mb-5`,children:[(0,m.jsx)(h,{level:3,children:`商品分类`}),(0,m.jsx)(g,{type:`secondary`,children:`多级分类(如蔬菜/水果/其他),采购单导出与对账筛选按分类归组;有子分类或挂载商品时不可删除。`})]}),(0,m.jsx)(l,{api:`/product/category`,columns:[{title:`分类名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入分类名称`}]},{title:`上级分类`,dataIndex:`parent_id`,hideInTable:!0,hideInSearch:!0,initialValue:0,fieldRender:e=>(0,m.jsx)(_,{form:e,tree:o})},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0},align:`center`},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,hideInSearch:!0,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=p[t.status??1];return(0,m.jsx)(c,{color:n?.color,children:n?.text})},align:`center`},{title:`创建时间`,dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`product.category`,handleRequest:async()=>{let e=(await d()).data.data??[];return r(v(e)),{data:e,total:e.length}},pagination:{pageSize:200},expandable:{expandedRowKeys:e,onExpandedRowsChange:e=>t([...e])},actionBarRender:r=>[r.add,(0,m.jsx)(a,{icon:(0,m.jsx)(i,{}),onClick:()=>t(e.length?[]:n),children:e.length?`全部收起`:`全部展开`}),r.keywordSearch],formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:640}})]})};export{y as default};
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-BS9orhKm.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./store-JDnJsv44.js";import{t as m}from"./download-DC9wDwqQ.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return m(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[``,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[``,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[m]=l.useForm();(0,h.useEffect)(()=>{p().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>m.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:m,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-DOLiJ5rL.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./store-JDnJsv44.js";import{t as m}from"./download-DC9wDwqQ.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return m(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[``,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[``,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[m]=l.useForm();(0,h.useEffect)(()=>{p().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>m.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:m,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default};
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{L as a}from"./lodash-DzOfPy0f.js";import{t as o}from"./tooltip-SaeG1Uv7.js";import{t as s}from"./table-BXi9q3AE.js";import{t as c}from"./button-BILozH6U.js";import{n as l}from"./LockOutlined-B8eRH1x4.js";import{t as u}from"./EyeOutlined-LSBUmW0e.js";import{t as d}from"./tag-DBV1bHre.js";import{t as f}from"./useTranslation-DBl6NYjI.js";import{t as p}from"./XinTable-BS9orhKm.js";async function m(e,t){return r({url:`/ai/conversation/${e}/messages`,method:`get`,params:t})}var h=e(l(),1),g=e(t(),1),_=n(),{Title:v,Text:y}=i;function b(){let{t:e}=f(),[t,n]=(0,g.useState)(!1),[r,i]=(0,g.useState)(``),[l,b]=(0,g.useState)([]),[x,S]=(0,g.useState)(!1),[C,w]=(0,g.useState)(0),[T,E]=(0,g.useState)(1),[D,O]=(0,g.useState)(``),k=async(e,t)=>{S(!0);try{let n=(await m(e,{page:t,pageSize:20})).data.data;b(n.data),w(n.total)}finally{S(!1)}},A=async e=>{O(e.id),i(e.title||``),E(1),n(!0),await k(e.id,1)},j=e=>{E(e),k(D,e)},M=[{title:e(`ai.conversation.id`),dataIndex:`id`,hideInForm:!0,width:260,ellipsis:!0,align:`center`},{title:e(`ai.conversation.username`),dataIndex:`username`,hideInForm:!0,align:`center`,width:120,render:t=>t||e(`ai.conversation.noUser`)},{title:e(`ai.conversation.title`),dataIndex:`title`,valueType:`text`,ellipsis:!0},{title:e(`ai.conversation.messageCount`),dataIndex:`message_count`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100},{title:e(`ai.conversation.createdAt`),dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`},{title:e(`ai.conversation.updatedAt`),dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`}],N=[{title:e(`ai.conversation.message.role`),dataIndex:`role`,width:100,render:t=>(0,_.jsx)(d,{color:{user:`blue`,assistant:`green`,system:`orange`}[t]||`default`,children:e(`ai.conversation.message.role.${t}`,t)})},{title:e(`ai.conversation.message.agent`),dataIndex:`agent`,width:150,ellipsis:!0},{title:e(`ai.conversation.message.content`),dataIndex:`content`,ellipsis:!0},{title:e(`ai.conversation.message.createdAt`),dataIndex:`created_at`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`}];return(0,_.jsxs)(_.Fragment,{children:[(0,_.jsxs)(`div`,{className:`mb-5`,children:[(0,_.jsx)(v,{level:3,children:e(`ai.conversation.page.title`)}),(0,_.jsx)(y,{type:`secondary`,children:e(`ai.conversation.page.description`)})]}),(0,_.jsx)(p,{api:`/ai/conversation`,columns:M,rowKey:`id`,accessName:`ai.conversation`,addShow:!1,editShow:!1,formProps:!1,operateProps:{fixed:`right`,width:120},operateRender:(t,n)=>[(0,_.jsx)(o,{title:e(`ai.conversation.viewMessages`),children:(0,_.jsx)(c,{type:`primary`,icon:(0,_.jsx)(u,{}),size:`small`,onClick:()=>A(t)})},`view`),n.del],scroll:{x:1100},cardProps:{variant:`borderless`}}),(0,_.jsx)(a,{title:`${e(`ai.conversation.messageTitle`)} - ${r}`,open:t,onClose:()=>n(!1),width:900,children:(0,_.jsx)(s,{dataSource:l,columns:N,rowKey:`id`,loading:x,pagination:{current:T,total:C,pageSize:20,onChange:j,showSizeChanger:!1},scroll:{x:700},size:`small`})})]})}export{b as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{L as a}from"./lodash-DzOfPy0f.js";import{t as o}from"./tooltip-SaeG1Uv7.js";import{t as s}from"./table-BXi9q3AE.js";import{t as c}from"./button-BILozH6U.js";import{n as l}from"./LockOutlined-B8eRH1x4.js";import{t as u}from"./EyeOutlined-LSBUmW0e.js";import{t as d}from"./tag-DBV1bHre.js";import{t as f}from"./useTranslation-DBl6NYjI.js";import{t as p}from"./XinTable-DOLiJ5rL.js";async function m(e,t){return r({url:`/ai/conversation/${e}/messages`,method:`get`,params:t})}var h=e(l(),1),g=e(t(),1),_=n(),{Title:v,Text:y}=i;function b(){let{t:e}=f(),[t,n]=(0,g.useState)(!1),[r,i]=(0,g.useState)(``),[l,b]=(0,g.useState)([]),[x,S]=(0,g.useState)(!1),[C,w]=(0,g.useState)(0),[T,E]=(0,g.useState)(1),[D,O]=(0,g.useState)(``),k=async(e,t)=>{S(!0);try{let n=(await m(e,{page:t,pageSize:20})).data.data;b(n.data),w(n.total)}finally{S(!1)}},A=async e=>{O(e.id),i(e.title||``),E(1),n(!0),await k(e.id,1)},j=e=>{E(e),k(D,e)},M=[{title:e(`ai.conversation.id`),dataIndex:`id`,hideInForm:!0,width:260,ellipsis:!0,align:`center`},{title:e(`ai.conversation.username`),dataIndex:`username`,hideInForm:!0,align:`center`,width:120,render:t=>t||e(`ai.conversation.noUser`)},{title:e(`ai.conversation.title`),dataIndex:`title`,valueType:`text`,ellipsis:!0},{title:e(`ai.conversation.messageCount`),dataIndex:`message_count`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100},{title:e(`ai.conversation.createdAt`),dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`},{title:e(`ai.conversation.updatedAt`),dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`}],N=[{title:e(`ai.conversation.message.role`),dataIndex:`role`,width:100,render:t=>(0,_.jsx)(d,{color:{user:`blue`,assistant:`green`,system:`orange`}[t]||`default`,children:e(`ai.conversation.message.role.${t}`,t)})},{title:e(`ai.conversation.message.agent`),dataIndex:`agent`,width:150,ellipsis:!0},{title:e(`ai.conversation.message.content`),dataIndex:`content`,ellipsis:!0},{title:e(`ai.conversation.message.createdAt`),dataIndex:`created_at`,width:180,render:e=>e?(0,h.default)(e).format(`YYYY-MM-DD HH:mm:ss`):`-`}];return(0,_.jsxs)(_.Fragment,{children:[(0,_.jsxs)(`div`,{className:`mb-5`,children:[(0,_.jsx)(v,{level:3,children:e(`ai.conversation.page.title`)}),(0,_.jsx)(y,{type:`secondary`,children:e(`ai.conversation.page.description`)})]}),(0,_.jsx)(p,{api:`/ai/conversation`,columns:M,rowKey:`id`,accessName:`ai.conversation`,addShow:!1,editShow:!1,formProps:!1,operateProps:{fixed:`right`,width:120},operateRender:(t,n)=>[(0,_.jsx)(o,{title:e(`ai.conversation.viewMessages`),children:(0,_.jsx)(c,{type:`primary`,icon:(0,_.jsx)(u,{}),size:`small`,onClick:()=>A(t)})},`view`),n.del],scroll:{x:1100},cardProps:{variant:`borderless`}}),(0,_.jsx)(a,{title:`${e(`ai.conversation.messageTitle`)} - ${r}`,open:t,onClose:()=>n(!1),width:900,children:(0,_.jsx)(s,{dataSource:l,columns:N,rowKey:`id`,loading:x,pagination:{current:T,total:C,pageSize:20,onChange:j,showSizeChanger:!1},scroll:{x:700},size:`small`})})]})}export{b as default};
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{t}from"./jsx-runtime-CRBytmvs.js";import{o as n}from"./chunk-KS7C4IRE-Zm15rq6F.js";import{t as r}from"./typography-DRFhazK9.js";import{a as i}from"./lodash-DzOfPy0f.js";import{t as a}from"./tooltip-SaeG1Uv7.js";import{t as o}from"./button-BILozH6U.js";import{t as s}from"./badge-__AeKia1.js";import{n as c}from"./LockOutlined-B8eRH1x4.js";import{t as l}from"./useTranslation-DBl6NYjI.js";import{t as u}from"./dict-CDRllPHM.js";import{t as d}from"./XinTable-BS9orhKm.js";var f=t(),p=e(c(),1),{Title:m,Text:h}=r;function g(){let{t:e}=l(),t=n(),r=u(e=>e.initDict),c=[{title:e(`system.dict.id`),dataIndex:`id`,hideInForm:!0,width:80,sorter:!0,align:`center`},{title:e(`system.dict.name`),dataIndex:`name`,valueType:`text`,colProps:{span:12},rules:[{required:!0,message:e(`system.dict.name.required`)}]},{title:e(`system.dict.code`),dataIndex:`code`,valueType:`text`,colProps:{span:12},rules:[{required:!0,message:e(`system.dict.code.required`)}]},{title:e(`system.dict.status`),dataIndex:`status`,valueType:`select`,filters:[{text:e(`system.dict.status.normal`),value:0},{text:e(`system.dict.status.disabled`),value:1}],colProps:{span:12},rules:[{required:!0,message:e(`system.dict.status.required`)}],fieldProps:{options:[{label:e(`system.dict.status.normal`),value:0},{label:e(`system.dict.status.disabled`),value:1}]},render:t=>t===0?(0,f.jsx)(s,{status:`success`,text:e(`system.dict.status.normal`)}):(0,f.jsx)(s,{status:`error`,text:e(`system.dict.status.disabled`)})},{title:e(`system.dict.sort`),dataIndex:`sort`,valueType:`digit`,colProps:{span:12},hideInSearch:!0,fieldProps:{min:0,style:{width:`100%`}}},{title:e(`system.dict.describe`),dataIndex:`describe`,valueType:`textarea`,colProps:{span:24},hideInSearch:!0,ellipsis:!0},{title:e(`system.dict.createdAt`),dataIndex:`created_at`,render:e=>e?(0,p.default)(e).format(`YYYY-MM-DD HH:mm`):`-`,hideInForm:!0,hideInSearch:!0,width:160}],g=async()=>{await r(),window.$message?.success(e(`system.dict.refreshSuccess`))},_=e=>{t(`/system/dict/item?dictId=${e.id}&dictName=${encodeURIComponent(e.name||``)}&dictCode=${e.code}`)};return(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`mb-5`,children:[(0,f.jsx)(m,{level:3,children:e(`system.dict.page.title`)}),(0,f.jsx)(h,{type:`secondary`,children:e(`system.dict.page.description`)})]}),(0,f.jsx)(d,{api:`/system/dict/list`,columns:c,rowKey:`id`,accessName:`system.dict.list`,searchProps:!1,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:[30,0]},layout:`vertical`},modalProps:{width:800},actionBarRender:t=>[t.add,(0,f.jsx)(o,{type:`primary`,onClick:g,children:e(`system.dict.refreshCache`)},`refresh`),t.keywordSearch],operateProps:{fixed:`right`,width:180},scroll:{x:1e3},operateRender:(t,n)=>[(0,f.jsx)(a,{title:e(`system.dict.manageItems`),children:(0,f.jsx)(o,{type:`default`,icon:(0,f.jsx)(i,{}),size:`small`,onClick:()=>_(t)})}),n.edit,n.del]})]})}export{g as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{t}from"./jsx-runtime-CRBytmvs.js";import{o as n}from"./chunk-KS7C4IRE-Zm15rq6F.js";import{t as r}from"./typography-DRFhazK9.js";import{a as i}from"./lodash-DzOfPy0f.js";import{t as a}from"./tooltip-SaeG1Uv7.js";import{t as o}from"./button-BILozH6U.js";import{t as s}from"./badge-__AeKia1.js";import{n as c}from"./LockOutlined-B8eRH1x4.js";import{t as l}from"./useTranslation-DBl6NYjI.js";import{t as u}from"./dict-CDRllPHM.js";import{t as d}from"./XinTable-DOLiJ5rL.js";var f=t(),p=e(c(),1),{Title:m,Text:h}=r;function g(){let{t:e}=l(),t=n(),r=u(e=>e.initDict),c=[{title:e(`system.dict.id`),dataIndex:`id`,hideInForm:!0,width:80,sorter:!0,align:`center`},{title:e(`system.dict.name`),dataIndex:`name`,valueType:`text`,colProps:{span:12},rules:[{required:!0,message:e(`system.dict.name.required`)}]},{title:e(`system.dict.code`),dataIndex:`code`,valueType:`text`,colProps:{span:12},rules:[{required:!0,message:e(`system.dict.code.required`)}]},{title:e(`system.dict.status`),dataIndex:`status`,valueType:`select`,filters:[{text:e(`system.dict.status.normal`),value:0},{text:e(`system.dict.status.disabled`),value:1}],colProps:{span:12},rules:[{required:!0,message:e(`system.dict.status.required`)}],fieldProps:{options:[{label:e(`system.dict.status.normal`),value:0},{label:e(`system.dict.status.disabled`),value:1}]},render:t=>t===0?(0,f.jsx)(s,{status:`success`,text:e(`system.dict.status.normal`)}):(0,f.jsx)(s,{status:`error`,text:e(`system.dict.status.disabled`)})},{title:e(`system.dict.sort`),dataIndex:`sort`,valueType:`digit`,colProps:{span:12},hideInSearch:!0,fieldProps:{min:0,style:{width:`100%`}}},{title:e(`system.dict.describe`),dataIndex:`describe`,valueType:`textarea`,colProps:{span:24},hideInSearch:!0,ellipsis:!0},{title:e(`system.dict.createdAt`),dataIndex:`created_at`,render:e=>e?(0,p.default)(e).format(`YYYY-MM-DD HH:mm`):`-`,hideInForm:!0,hideInSearch:!0,width:160}],g=async()=>{await r(),window.$message?.success(e(`system.dict.refreshSuccess`))},_=e=>{t(`/system/dict/item?dictId=${e.id}&dictName=${encodeURIComponent(e.name||``)}&dictCode=${e.code}`)};return(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`mb-5`,children:[(0,f.jsx)(m,{level:3,children:e(`system.dict.page.title`)}),(0,f.jsx)(h,{type:`secondary`,children:e(`system.dict.page.description`)})]}),(0,f.jsx)(d,{api:`/system/dict/list`,columns:c,rowKey:`id`,accessName:`system.dict.list`,searchProps:!1,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:[30,0]},layout:`vertical`},modalProps:{width:800},actionBarRender:t=>[t.add,(0,f.jsx)(o,{type:`primary`,onClick:g,children:e(`system.dict.refreshCache`)},`refresh`),t.keywordSearch],operateProps:{fixed:`right`,width:180},scroll:{x:1e3},operateRender:(t,n)=>[(0,f.jsx)(a,{title:e(`system.dict.manageItems`),children:(0,f.jsx)(o,{type:`default`,icon:(0,f.jsx)(i,{}),size:`small`,onClick:()=>_(t)})}),n.edit,n.del]})]})}export{g as default};
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 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{o as r,s as i}from"./chunk-KS7C4IRE-Zm15rq6F.js";import{t as a}from"./typography-DRFhazK9.js";import{t as o}from"./space-Cu4QVMgQ.js";import{t as s}from"./button-BILozH6U.js";import{t as c}from"./LeftOutlined-8zj_FQJP.js";import{t as l}from"./badge-__AeKia1.js";import{n as u}from"./LockOutlined-B8eRH1x4.js";import{i as d,s as f}from"./XinForm-BPDYdeax.js";import{t as p}from"./tag-DBV1bHre.js";import{t as m}from"./useTranslation-DBl6NYjI.js";import{t as h}from"./XinTable-BS9orhKm.js";var g=[{label:`默认`,value:`default`},{label:`蓝色`,value:`blue`},{label:`绿色`,value:`green`},{label:`红色`,value:`red`},{label:`橙色`,value:`orange`},{label:`紫色`,value:`purple`},{label:`青色`,value:`cyan`},{label:`金色`,value:`gold`},{label:`绿黄色`,value:`lime`},{label:`极客蓝`,value:`geekblue`},{label:`品红`,value:`magenta`},{label:`火山红`,value:`volcano`}],_=e(t(),1),v=e(u(),1),y=n();function b(){let{t:e}=m(),t=r(),[n]=i(),u=n.get(`dictId`),b=n.get(`dictName`)||``,x=n.get(`dictCode`)||``,[S,C]=(0,_.useState)({id:u?parseInt(u):0,name:decodeURIComponent(b),code:x});(0,_.useEffect)(()=>{u&&C({id:parseInt(u),name:decodeURIComponent(b),code:x})},[u,b,x]);let w=[{title:e(`system.system.dict.item.id`),dataIndex:`id`,hideInForm:!0,width:80,align:`center`},{title:e(`system.system.dict.item.label`),dataIndex:`label`,valueType:`text`,rules:[{required:!0,message:e(`system.system.dict.item.label.required`)}]},{title:e(`system.system.dict.item.value`),dataIndex:`value`,valueType:`text`,rules:[{required:!0,message:e(`system.system.dict.item.value.required`)}]},{title:e(`system.system.dict.item.color`),dataIndex:`color`,valueType:`select`,colProps:{span:12},initialValue:`default`,fieldProps:{options:g.map(e=>({label:(0,y.jsx)(p,{color:e.value,children:e.label}),value:e.value}))},render:e=>(0,y.jsx)(p,{color:e,children:g.find(t=>t.value===e)?.label||e})},{title:e(`system.system.dict.item.isDefault`),dataIndex:`is_default`,valueType:`select`,colProps:{span:12},initialValue:0,rules:[{required:!0,message:e(`system.system.dict.item.isDefault.required`)}],fieldProps:{options:[{label:e(`system.system.dict.item.isDefault.yes`),value:1},{label:e(`system.system.dict.item.isDefault.no`),value:0}]},render:t=>t===1?(0,y.jsx)(p,{color:`blue`,children:e(`system.system.dict.item.isDefault.yes`)}):e(`system.system.dict.item.isDefault.no`)},{title:e(`system.system.dict.item.sort`),dataIndex:`sort`,valueType:`digit`,colProps:{span:12},hideInSearch:!0,initialValue:0,fieldProps:{min:0,style:{width:`100%`}}},{title:e(`system.system.dict.item.status`),dataIndex:`status`,valueType:`select`,colProps:{span:12},initialValue:0,rules:[{required:!0,message:e(`system.system.dict.item.status.required`)}],fieldProps:{options:[{label:e(`system.system.dict.item.status.normal`),value:0},{label:e(`system.system.dict.item.status.disabled`),value:1}]},render:t=>t===0?(0,y.jsx)(l,{status:`success`,text:e(`system.system.dict.item.status.normal`)}):(0,y.jsx)(l,{status:`error`,text:e(`system.system.dict.item.status.disabled`)})},{title:e(`system.system.dict.item.createTime`),dataIndex:`created_at`,render:e=>e?(0,v.default)(e).format(`YYYY-MM-DD HH:mm`):`-`,hideInForm:!0,hideInSearch:!0,width:160}];return S.id?(0,y.jsxs)(o,{orientation:`vertical`,style:{width:`100%`},children:[(0,y.jsxs)(`div`,{children:[(0,y.jsx)(s,{type:`link`,onClick:()=>{t(`/system/dict`)},icon:(0,y.jsx)(c,{}),classNames:{root:`p-0 mb-2`},children:e(`system.dict.backToList`)}),(0,y.jsxs)(a.Title,{level:3,children:[(0,y.jsxs)(`span`,{className:`mr-2`,children:[e(`system.dict.itemManagement`),` - `,S.name]}),(0,y.jsx)(a.Text,{type:`secondary`,children:S.code})]})]}),(0,y.jsx)(h,{api:`/system/dict/item`,columns:w,rowKey:`id`,accessName:`system.dict.item`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:[30,0]}},modalProps:{width:600},requestParams:e=>({...e,dict_id:S.id}),searchShow:!1,handleFinish:async(t,n,r,i)=>(n===`create`?(await d(`/system/dict/item`,{...t,dict_id:S.id}),window.$message?.success(e(`system.dict.item.createSuccess`))):(await f(`/system/dict/item/`+i?.id,{...t,dict_id:S.id}),window.$message?.success(e(`system.dict.item.updateSuccess`))),!0)})]}):(0,y.jsx)(`div`,{style:{padding:50,textAlign:`center`},children:(0,y.jsx)(`div`,{style:{marginTop:50,color:`#999`},children:e(`system.dict.selectDictFirst`)})})}export{b as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{o as r,s as i}from"./chunk-KS7C4IRE-Zm15rq6F.js";import{t as a}from"./typography-DRFhazK9.js";import{t as o}from"./space-Cu4QVMgQ.js";import{t as s}from"./button-BILozH6U.js";import{t as c}from"./LeftOutlined-8zj_FQJP.js";import{t as l}from"./badge-__AeKia1.js";import{n as u}from"./LockOutlined-B8eRH1x4.js";import{i as d,s as f}from"./XinForm-BPDYdeax.js";import{t as p}from"./tag-DBV1bHre.js";import{t as m}from"./useTranslation-DBl6NYjI.js";import{t as h}from"./XinTable-DOLiJ5rL.js";var g=[{label:`默认`,value:`default`},{label:`蓝色`,value:`blue`},{label:`绿色`,value:`green`},{label:`红色`,value:`red`},{label:`橙色`,value:`orange`},{label:`紫色`,value:`purple`},{label:`青色`,value:`cyan`},{label:`金色`,value:`gold`},{label:`绿黄色`,value:`lime`},{label:`极客蓝`,value:`geekblue`},{label:`品红`,value:`magenta`},{label:`火山红`,value:`volcano`}],_=e(t(),1),v=e(u(),1),y=n();function b(){let{t:e}=m(),t=r(),[n]=i(),u=n.get(`dictId`),b=n.get(`dictName`)||``,x=n.get(`dictCode`)||``,[S,C]=(0,_.useState)({id:u?parseInt(u):0,name:decodeURIComponent(b),code:x});(0,_.useEffect)(()=>{u&&C({id:parseInt(u),name:decodeURIComponent(b),code:x})},[u,b,x]);let w=[{title:e(`system.system.dict.item.id`),dataIndex:`id`,hideInForm:!0,width:80,align:`center`},{title:e(`system.system.dict.item.label`),dataIndex:`label`,valueType:`text`,rules:[{required:!0,message:e(`system.system.dict.item.label.required`)}]},{title:e(`system.system.dict.item.value`),dataIndex:`value`,valueType:`text`,rules:[{required:!0,message:e(`system.system.dict.item.value.required`)}]},{title:e(`system.system.dict.item.color`),dataIndex:`color`,valueType:`select`,colProps:{span:12},initialValue:`default`,fieldProps:{options:g.map(e=>({label:(0,y.jsx)(p,{color:e.value,children:e.label}),value:e.value}))},render:e=>(0,y.jsx)(p,{color:e,children:g.find(t=>t.value===e)?.label||e})},{title:e(`system.system.dict.item.isDefault`),dataIndex:`is_default`,valueType:`select`,colProps:{span:12},initialValue:0,rules:[{required:!0,message:e(`system.system.dict.item.isDefault.required`)}],fieldProps:{options:[{label:e(`system.system.dict.item.isDefault.yes`),value:1},{label:e(`system.system.dict.item.isDefault.no`),value:0}]},render:t=>t===1?(0,y.jsx)(p,{color:`blue`,children:e(`system.system.dict.item.isDefault.yes`)}):e(`system.system.dict.item.isDefault.no`)},{title:e(`system.system.dict.item.sort`),dataIndex:`sort`,valueType:`digit`,colProps:{span:12},hideInSearch:!0,initialValue:0,fieldProps:{min:0,style:{width:`100%`}}},{title:e(`system.system.dict.item.status`),dataIndex:`status`,valueType:`select`,colProps:{span:12},initialValue:0,rules:[{required:!0,message:e(`system.system.dict.item.status.required`)}],fieldProps:{options:[{label:e(`system.system.dict.item.status.normal`),value:0},{label:e(`system.system.dict.item.status.disabled`),value:1}]},render:t=>t===0?(0,y.jsx)(l,{status:`success`,text:e(`system.system.dict.item.status.normal`)}):(0,y.jsx)(l,{status:`error`,text:e(`system.system.dict.item.status.disabled`)})},{title:e(`system.system.dict.item.createTime`),dataIndex:`created_at`,render:e=>e?(0,v.default)(e).format(`YYYY-MM-DD HH:mm`):`-`,hideInForm:!0,hideInSearch:!0,width:160}];return S.id?(0,y.jsxs)(o,{orientation:`vertical`,style:{width:`100%`},children:[(0,y.jsxs)(`div`,{children:[(0,y.jsx)(s,{type:`link`,onClick:()=>{t(`/system/dict`)},icon:(0,y.jsx)(c,{}),classNames:{root:`p-0 mb-2`},children:e(`system.dict.backToList`)}),(0,y.jsxs)(a.Title,{level:3,children:[(0,y.jsxs)(`span`,{className:`mr-2`,children:[e(`system.dict.itemManagement`),` - `,S.name]}),(0,y.jsx)(a.Text,{type:`secondary`,children:S.code})]})]}),(0,y.jsx)(h,{api:`/system/dict/item`,columns:w,rowKey:`id`,accessName:`system.dict.item`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:[30,0]}},modalProps:{width:600},requestParams:e=>({...e,dict_id:S.id}),searchShow:!1,handleFinish:async(t,n,r,i)=>(n===`create`?(await d(`/system/dict/item`,{...t,dict_id:S.id}),window.$message?.success(e(`system.dict.item.createSuccess`))):(await f(`/system/dict/item/`+i?.id,{...t,dict_id:S.id}),window.$message?.success(e(`system.dict.item.updateSuccess`))),!0)})]}):(0,y.jsx)(`div`,{style:{padding:50,textAlign:`center`},children:(0,y.jsx)(`div`,{style:{marginTop:50,color:`#999`},children:e(`system.dict.selectDictFirst`)})})}export{b as default};
@@ -1 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-BS9orhKm.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`客户等级`}),(0,s.jsx)(l,{type:`secondary`,children:`按等级配置价格上浮比例:售价 = 成本价 × (100 + 上浮比例) / 100,门店绑定等级后小程序端按对应价格展示。`})]}),(0,s.jsx)(a,{api:`/customer/level`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`等级名称`,dataIndex:`name`,valueType:`text`,required:!0,align:`center`,rules:[{required:!0,message:`请输入等级名称`}]},{title:`价格上浮比例`,dataIndex:`percent`,valueType:`digit`,hideInSearch:!0,initialValue:0,tooltip:`该等级售价 = 成本价 × (100 + 上浮比例) / 100`,fieldProps:{min:0,max:999.99,precision:2,suffix:`%`,placeholder:`如 30 表示成本价上浮 30%`},align:`center`,render:(e,t)=>(0,s.jsxs)(i,{color:`blue`,children:[Number(t.percent??0),`%`]})},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0}},{title:`图片等级`,dataIndex:`icon_id`,valueType:`image`,fieldProps:{action:`/customer/level/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.icon_url;return n?(0,s.jsx)(r,{src:n,width:30,height:30,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})}},{title:`创建时间`,dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`customer.level`,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:640}})]});export{u as default}; import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-DOLiJ5rL.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`客户等级`}),(0,s.jsx)(l,{type:`secondary`,children:`按等级配置价格上浮比例:售价 = 成本价 × (100 + 上浮比例) / 100,门店绑定等级后小程序端按对应价格展示。`})]}),(0,s.jsx)(a,{api:`/customer/level`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`等级名称`,dataIndex:`name`,valueType:`text`,required:!0,align:`center`,rules:[{required:!0,message:`请输入等级名称`}]},{title:`价格上浮比例`,dataIndex:`percent`,valueType:`digit`,hideInSearch:!0,initialValue:0,tooltip:`该等级售价 = 成本价 × (100 + 上浮比例) / 100`,fieldProps:{min:0,max:999.99,precision:2,suffix:`%`,placeholder:`如 30 表示成本价上浮 30%`},align:`center`,render:(e,t)=>(0,s.jsxs)(i,{color:`blue`,children:[Number(t.percent??0),`%`]})},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0}},{title:`图片等级`,dataIndex:`icon_id`,valueType:`image`,fieldProps:{action:`/customer/level/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.icon_url;return n?(0,s.jsx)(r,{src:n,width:30,height:30,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})}},{title:`创建时间`,dataIndex:`updated_at`,hideInForm:!0,hideInSearch:!0,align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`customer.level`,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:640}})]});export{u as default};
@@ -1 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-BS9orhKm.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`宫格导航`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页宫格入口(如商品分类、促销活动等);停用后不展示,排序越小越靠前。`})]}),(0,s.jsx)(a,{api:`/client/nav`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`导航名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入导航名称`}]},{title:`导航图标`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传导航图标`}],fieldProps:{action:`/client/nav/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:40,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/category/index`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.nav`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:640}})]});export{u as default}; import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-DOLiJ5rL.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`宫格导航`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页宫格入口(如商品分类、促销活动等);停用后不展示,排序越小越靠前。`})]}),(0,s.jsx)(a,{api:`/client/nav`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`导航名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入导航名称`}]},{title:`导航图标`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传导航图标`}],fieldProps:{action:`/client/nav/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:40,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/category/index`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.nav`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:640}})]});export{u as default};
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-BS9orhKm.js";import{t as o}from"./store-JDnJsv44.js";var s=e(t(),1),c={order:{text:`订单`,color:`blue`},price:{text:`价格`,color:`gold`},system:{text:`系统`,color:`default`}},l={0:{text:`未读`,color:`warning`},1:{text:`已读`,color:`default`}},u=n(),{Title:d,Text:f}=r,p=()=>{let[e,t]=(0,s.useState)([]);(0,s.useEffect)(()=>{o().then(e=>t(e.data.data??[]))},[]);let n=new Map(e.map(e=>[e.id,e.name])),r={api:`/customer/notice`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`标题`,dataIndex:`title`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入通知标题`}]},{title:`类型`,dataIndex:`type`,valueType:`select`,initialValue:`system`,required:!0,rules:[{required:!0,message:`请选择通知类型`}],fieldProps:{options:[{value:`system`,label:`系统`},{value:`order`,label:`订单`},{value:`price`,label:`价格`}]},render:(e,t)=>{let n=c[t.type??`system`];return(0,u.jsx)(i,{color:n?.color,children:n?.text})}},{title:`接收对象`,dataIndex:`store_id`,valueType:`select`,hideInSearch:!0,initialValue:0,fieldProps:{options:[{label:`全员广播`,value:0},...e.map(e=>({label:e.name??``,value:e.id??0}))],showSearch:!0,optionFilterProp:`label`,placeholder:`默认全员广播`},align:`center`,render:(e,t)=>t.store_id===0?(0,u.jsx)(i,{color:`gold`,children:`全员广播`}):(0,u.jsx)(i,{children:n.get(t.store_id)??`门店 #${t.store_id}`})},{title:`内容`,dataIndex:`content`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:3}},{title:`阅读状态`,dataIndex:`is_read`,valueType:`select`,hideInForm:!0,fieldProps:{options:[{value:0,label:`未读`},{value:1,label:`已读`}]},render:(e,t)=>{let n=l[t.is_read??0];return(0,u.jsx)(i,{color:n?.color,children:n?.text})},align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`customer.notice`,editShow:()=>!1,formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}};return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsxs)(`div`,{className:`mb-5`,children:[(0,u.jsx)(d,{level:3,children:`通知管理`}),(0,u.jsx)(f,{type:`secondary`,children:`向门店端小程序发送消息;接收对象默认全员广播,价格调整通知由批量调价自动生成。`})]}),(0,u.jsx)(a,{...r})]})};export{p as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-DOLiJ5rL.js";import{t as o}from"./store-JDnJsv44.js";var s=e(t(),1),c={order:{text:`订单`,color:`blue`},price:{text:`价格`,color:`gold`},system:{text:`系统`,color:`default`}},l={0:{text:`未读`,color:`warning`},1:{text:`已读`,color:`default`}},u=n(),{Title:d,Text:f}=r,p=()=>{let[e,t]=(0,s.useState)([]);(0,s.useEffect)(()=>{o().then(e=>t(e.data.data??[]))},[]);let n=new Map(e.map(e=>[e.id,e.name])),r={api:`/customer/notice`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`标题`,dataIndex:`title`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入通知标题`}]},{title:`类型`,dataIndex:`type`,valueType:`select`,initialValue:`system`,required:!0,rules:[{required:!0,message:`请选择通知类型`}],fieldProps:{options:[{value:`system`,label:`系统`},{value:`order`,label:`订单`},{value:`price`,label:`价格`}]},render:(e,t)=>{let n=c[t.type??`system`];return(0,u.jsx)(i,{color:n?.color,children:n?.text})}},{title:`接收对象`,dataIndex:`store_id`,valueType:`select`,hideInSearch:!0,initialValue:0,fieldProps:{options:[{label:`全员广播`,value:0},...e.map(e=>({label:e.name??``,value:e.id??0}))],showSearch:!0,optionFilterProp:`label`,placeholder:`默认全员广播`},align:`center`,render:(e,t)=>t.store_id===0?(0,u.jsx)(i,{color:`gold`,children:`全员广播`}):(0,u.jsx)(i,{children:n.get(t.store_id)??`门店 #${t.store_id}`})},{title:`内容`,dataIndex:`content`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:3}},{title:`阅读状态`,dataIndex:`is_read`,valueType:`select`,hideInForm:!0,fieldProps:{options:[{value:0,label:`未读`},{value:1,label:`已读`}]},render:(e,t)=>{let n=l[t.is_read??0];return(0,u.jsx)(i,{color:n?.color,children:n?.text})},align:`center`},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`customer.notice`,editShow:()=>!1,formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}};return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsxs)(`div`,{className:`mb-5`,children:[(0,u.jsx)(d,{level:3,children:`通知管理`}),(0,u.jsx)(f,{type:`secondary`,children:`向门店端小程序发送消息;接收对象默认全员广播,价格调整通知由批量调价自动生成。`})]}),(0,u.jsx)(a,{...r})]})};export{p as default};
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 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-BS9orhKm.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`促销推荐卡片`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页促销位卡片;副标题展示促销文案,停用后不展示,排序越小越靠前。`})]}),(0,s.jsx)(a,{api:`/client/promo`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`卡片标题`,dataIndex:`title`,valueType:`text`},{title:`副标题`,dataIndex:`sub_title`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`促销文案,如「限时特惠 8 折起」`},render:(e,t)=>t.sub_title||`-`},{title:`卡片图片`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传卡片图片`}],fieldProps:{action:`/client/promo/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:80,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/promo/detail?id=1`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.promo`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}})]});export{u as default}; import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./image-CTRBNpBE.js";import{t as i}from"./tag-DBV1bHre.js";import{t as a}from"./XinTable-DOLiJ5rL.js";e();var o={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},s=t(),{Title:c,Text:l}=n,u=()=>(0,s.jsxs)(s.Fragment,{children:[(0,s.jsxs)(`div`,{className:`mb-5`,children:[(0,s.jsx)(c,{level:3,children:`促销推荐卡片`}),(0,s.jsx)(l,{type:`secondary`,children:`小程序首页促销位卡片;副标题展示促销文案,停用后不展示,排序越小越靠前。`})]}),(0,s.jsx)(a,{api:`/client/promo`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`卡片标题`,dataIndex:`title`,valueType:`text`},{title:`副标题`,dataIndex:`sub_title`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`促销文案,如「限时特惠 8 折起」`},render:(e,t)=>t.sub_title||`-`},{title:`卡片图片`,dataIndex:`image_id`,valueType:`image`,required:!0,rules:[{required:!0,message:`请上传卡片图片`}],fieldProps:{action:`/client/promo/upload`,mode:`single`,maxCount:1,changeType:`id`},render:(e,t)=>{let n=t.image_url;return n?(0,s.jsx)(r,{src:n,width:80,height:40,style:{objectFit:`cover`,borderRadius:4}}):`-`},align:`center`,hideInSearch:!0},{title:`跳转链接`,dataIndex:`link`,valueType:`text`,hideInSearch:!0,fieldProps:{placeholder:`小程序页面路径,如 /pages/promo/detail?id=1`},render:(e,t)=>t.link||`-`},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=o[t.status??1];return(0,s.jsx)(i,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.promo`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}})]});export{u as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{t as a}from"./tag-DBV1bHre.js";import{t as o}from"./XinTable-BS9orhKm.js";var s=e(t(),1),c={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}};async function l(){return r({url:`/customer/level/options`,method:`get`})}var u=n(),{Title:d,Text:f}=i,p=()=>{let[e,t]=(0,s.useState)([]);(0,s.useEffect)(()=>{l().then(e=>t(e.data.data??[]))},[]);let n={api:`/customer/store`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`门店名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入门店名称`}]},{title:`门店编码`,dataIndex:`code`,valueType:`text`,hideInForm:!0},{title:`登录账号`,dataIndex:`username`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入登录账号`},{min:4,max:20,message:`账号长度为 4~20 个字符`}],fieldProps:{placeholder:`小程序端登录账号`}},{title:`登录密码`,dataIndex:`password`,valueType:`password`,hideInTable:!0,hideInSearch:!0,rules:[{min:6,max:20,message:`密码长度为 6~20 位`}],fieldProps:{placeholder:`创建必填;编辑留空则不修改`}},{title:`客户等级`,dataIndex:`level_id`,valueType:`select`,required:!0,rules:[{required:!0,message:`请选择客户等级`}],fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.level?(0,u.jsx)(a,{color:`blue`,children:t.level.name}):(0,u.jsx)(a,{children:`未设置`})},{title:`联系人`,dataIndex:`contact`,valueType:`text`,hideInSearch:!0},{title:`联系电话`,dataIndex:`phone`,valueType:`text`,hideInSearch:!0},{title:`门店地址`,dataIndex:`address`,valueType:`textarea`,hideInSearch:!0,fieldProps:{rows:1},colProps:{span:24}},{title:`回款周期(天)`,dataIndex:`payment_cycle_days`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`},{title:`总采购金额`,dataIndex:`total_purchase_amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,u.jsxs)(f,{strong:!0,className:`text-[red]`,children:[`¥`,t.total_purchase_amount??`0.00`]})},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=c[t.status??1];return(0,u.jsx)(a,{color:n?.color,children:n?.text})}},{title:`备注`,dataIndex:`remark`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2},colProps:{span:24}}],rowKey:`id`,accessName:`customer.store`,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:720}};return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsxs)(`div`,{className:`mb-5`,children:[(0,u.jsx)(d,{level:3,children:`门店管理`}),(0,u.jsx)(f,{type:`secondary`,children:`门店即客户,小程序下单主体;登录账号/密码即门店端小程序登录凭证,客户等级决定商品价格,回款周期影响对账单应结算日期。`})]}),(0,u.jsx)(o,{...n})]})};export{p as default}; import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{t as a}from"./tag-DBV1bHre.js";import{t as o}from"./XinTable-DOLiJ5rL.js";var s=e(t(),1),c={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}};async function l(){return r({url:`/customer/level/options`,method:`get`})}var u=n(),{Title:d,Text:f}=i,p=()=>{let[e,t]=(0,s.useState)([]);(0,s.useEffect)(()=>{l().then(e=>t(e.data.data??[]))},[]);let n={api:`/customer/store`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`门店名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入门店名称`}]},{title:`门店编码`,dataIndex:`code`,valueType:`text`,hideInForm:!0},{title:`登录账号`,dataIndex:`username`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入登录账号`},{min:4,max:20,message:`账号长度为 4~20 个字符`}],fieldProps:{placeholder:`小程序端登录账号`}},{title:`登录密码`,dataIndex:`password`,valueType:`password`,hideInTable:!0,hideInSearch:!0,rules:[{min:6,max:20,message:`密码长度为 6~20 位`}],fieldProps:{placeholder:`创建必填;编辑留空则不修改`}},{title:`客户等级`,dataIndex:`level_id`,valueType:`select`,required:!0,rules:[{required:!0,message:`请选择客户等级`}],fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.level?(0,u.jsx)(a,{color:`blue`,children:t.level.name}):(0,u.jsx)(a,{children:`未设置`})},{title:`联系人`,dataIndex:`contact`,valueType:`text`,hideInSearch:!0},{title:`联系电话`,dataIndex:`phone`,valueType:`text`,hideInSearch:!0},{title:`门店地址`,dataIndex:`address`,valueType:`textarea`,hideInSearch:!0,fieldProps:{rows:1},colProps:{span:24}},{title:`回款周期(天)`,dataIndex:`payment_cycle_days`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`},{title:`总采购金额`,dataIndex:`total_purchase_amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,u.jsxs)(f,{strong:!0,className:`text-[red]`,children:[`¥`,t.total_purchase_amount??`0.00`]})},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=c[t.status??1];return(0,u.jsx)(a,{color:n?.color,children:n?.text})}},{title:`备注`,dataIndex:`remark`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2},colProps:{span:24}}],rowKey:`id`,accessName:`customer.store`,formProps:{grid:!0,colProps:{span:12},rowProps:{gutter:20},layout:`vertical`},modalProps:{width:720}};return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsxs)(`div`,{className:`mb-5`,children:[(0,u.jsx)(d,{level:3,children:`门店管理`}),(0,u.jsx)(f,{type:`secondary`,children:`门店即客户,小程序下单主体;登录账号/密码即门店端小程序登录凭证,客户等级决定商品价格,回款周期影响对账单应结算日期。`})]}),(0,u.jsx)(o,{...n})]})};export{p as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./tag-DBV1bHre.js";import{t as i}from"./XinTable-BS9orhKm.js";e();var a={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},o=t(),{Title:s,Text:c}=n,l=()=>(0,o.jsxs)(o.Fragment,{children:[(0,o.jsxs)(`div`,{className:`mb-5`,children:[(0,o.jsx)(s,{level:3,children:`供应商管理`}),(0,o.jsx)(c,{type:`secondary`,children:`采购单接收方,供应商小程序端接收并确认采购单。`})]}),(0,o.jsx)(i,{api:`/customer/supplier`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`供应商名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入供应商名称`}]},{title:`联系人`,dataIndex:`contact`,valueType:`text`,hideInSearch:!0},{title:`联系电话`,dataIndex:`phone`,valueType:`text`,hideInSearch:!0},{title:`主营品类`,dataIndex:`main_products`,valueType:`text`,hideInSearch:!0,render:(e,t)=>t.main_products?t.main_products.split(`/`).map(e=>(0,o.jsx)(r,{color:`green`,children:e},e)):`-`},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=a[t.status??1];return(0,o.jsx)(r,{color:n?.color,children:n?.text})}},{title:`地址`,dataIndex:`address`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2}},{title:`备注`,dataIndex:`remark`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2}}],rowKey:`id`,accessName:`customer.supplier`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:720}})]});export{l as default}; import"./rolldown-runtime-BgaNhQyE.js";import{Dr as e,t}from"./jsx-runtime-CRBytmvs.js";import{t as n}from"./typography-DRFhazK9.js";import{t as r}from"./tag-DBV1bHre.js";import{t as i}from"./XinTable-DOLiJ5rL.js";e();var a={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}},o=t(),{Title:s,Text:c}=n,l=()=>(0,o.jsxs)(o.Fragment,{children:[(0,o.jsxs)(`div`,{className:`mb-5`,children:[(0,o.jsx)(s,{level:3,children:`供应商管理`}),(0,o.jsx)(c,{type:`secondary`,children:`采购单接收方,供应商小程序端接收并确认采购单。`})]}),(0,o.jsx)(i,{api:`/customer/supplier`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`供应商名称`,dataIndex:`name`,valueType:`text`,required:!0,rules:[{required:!0,message:`请输入供应商名称`}]},{title:`联系人`,dataIndex:`contact`,valueType:`text`,hideInSearch:!0},{title:`联系电话`,dataIndex:`phone`,valueType:`text`,hideInSearch:!0},{title:`主营品类`,dataIndex:`main_products`,valueType:`text`,hideInSearch:!0,render:(e,t)=>t.main_products?t.main_products.split(`/`).map(e=>(0,o.jsx)(r,{color:`green`,children:e},e)):`-`},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=a[t.status??1];return(0,o.jsx)(r,{color:n?.color,children:n?.text})}},{title:`地址`,dataIndex:`address`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2}},{title:`备注`,dataIndex:`remark`,valueType:`textarea`,hideInSearch:!0,hideInTable:!0,fieldProps:{rows:2}}],rowKey:`id`,accessName:`customer.supplier`,formProps:{grid:!0,colProps:{span:12},layout:`vertical`,rowProps:{gutter:24}},modalProps:{width:720}})]});export{l as default};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{t as e}from"./jsx-runtime-CRBytmvs.js";import{t}from"./button-BILozH6U.js";import{t as n}from"./tag-DBV1bHre.js";import{n as r}from"./FileDoneOutlined-Bp46bcBB.js";import{t as i}from"./XinTable-BS9orhKm.js";var a=e(),o=Array.from({length:50},(e,t)=>({id:t+1,name:`用户${t+1}`,email:`user${t+1}@example.com`,age:Math.floor(Math.random()*40)+20,status:+(Math.random()>.3),role:[`管理员`,`编辑`,`访客`][Math.floor(Math.random()*3)],department:[`技术部`,`产品部`,`运营部`,`市场部`][Math.floor(Math.random()*4)],createdAt:new Date(Date.now()-Math.random()*1e10).toLocaleDateString()})),s=()=>(0,a.jsx)(i,{columns:[{title:`序号`,width:60,valueType:`text`,dataIndex:`id`},{dataIndex:`name`,title:`用户名`,width:120,valueType:`text`,required:!0},{dataIndex:`email`,title:`邮箱`,valueType:`text`,width:200,ellipsis:!0},{dataIndex:`age`,title:`年龄`,width:80,valueType:`digit`,sorter:(e,t)=>e.age-t.age,hideInSearch:!0},{dataIndex:`status`,title:`状态`,width:100,valueType:`select`,render:e=>{let t={1:{text:`启用`,color:`green`},0:{text:`禁用`,color:`red`}}[e];return t?(0,a.jsx)(n,{color:t.color,children:t.text}):`-`},filters:[{text:`启用`,value:1},{text:`禁用`,value:0}]},{dataIndex:`role`,title:`角色`,width:100,valueType:`select`,fieldProps:{options:[{label:`管理员`,value:`管理员`},{label:`编辑`,value:`编辑`},{label:`访客`,value:`访客`}]}},{dataIndex:`department`,title:`部门`,width:120,valueType:`select`,fieldProps:{options:[{label:`技术部`,value:`技术部`},{label:`产品部`,value:`产品部`},{label:`运营部`,value:`运营部`},{label:`市场部`,value:`市场部`}]},hideInSearch:!0},{dataIndex:`createdAt`,title:`创建时间`,width:120,hideInForm:!0,hideInSearch:!0}],rowKey:`id`,dataSource:o,accessName:`system.user.list`,api:`/system-user/list`,toolBarRender:e=>[(0,a.jsx)(t,{icon:(0,a.jsx)(r,{}),children:`导出`},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]});export{s as default}; import{t as e}from"./jsx-runtime-CRBytmvs.js";import{t}from"./button-BILozH6U.js";import{t as n}from"./tag-DBV1bHre.js";import{n as r}from"./FileDoneOutlined-Bp46bcBB.js";import{t as i}from"./XinTable-DOLiJ5rL.js";var a=e(),o=Array.from({length:50},(e,t)=>({id:t+1,name:`用户${t+1}`,email:`user${t+1}@example.com`,age:Math.floor(Math.random()*40)+20,status:+(Math.random()>.3),role:[`管理员`,`编辑`,`访客`][Math.floor(Math.random()*3)],department:[`技术部`,`产品部`,`运营部`,`市场部`][Math.floor(Math.random()*4)],createdAt:new Date(Date.now()-Math.random()*1e10).toLocaleDateString()})),s=()=>(0,a.jsx)(i,{columns:[{title:`序号`,width:60,valueType:`text`,dataIndex:`id`},{dataIndex:`name`,title:`用户名`,width:120,valueType:`text`,required:!0},{dataIndex:`email`,title:`邮箱`,valueType:`text`,width:200,ellipsis:!0},{dataIndex:`age`,title:`年龄`,width:80,valueType:`digit`,sorter:(e,t)=>e.age-t.age,hideInSearch:!0},{dataIndex:`status`,title:`状态`,width:100,valueType:`select`,render:e=>{let t={1:{text:`启用`,color:`green`},0:{text:`禁用`,color:`red`}}[e];return t?(0,a.jsx)(n,{color:t.color,children:t.text}):`-`},filters:[{text:`启用`,value:1},{text:`禁用`,value:0}]},{dataIndex:`role`,title:`角色`,width:100,valueType:`select`,fieldProps:{options:[{label:`管理员`,value:`管理员`},{label:`编辑`,value:`编辑`},{label:`访客`,value:`访客`}]}},{dataIndex:`department`,title:`部门`,width:120,valueType:`select`,fieldProps:{options:[{label:`技术部`,value:`技术部`},{label:`产品部`,value:`产品部`},{label:`运营部`,value:`运营部`},{label:`市场部`,value:`市场部`}]},hideInSearch:!0},{dataIndex:`createdAt`,title:`创建时间`,width:120,hideInForm:!0,hideInSearch:!0}],rowKey:`id`,dataSource:o,accessName:`system.user.list`,api:`/system-user/list`,toolBarRender:e=>[(0,a.jsx)(t,{icon:(0,a.jsx)(r,{}),children:`导出`},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]});export{s as default};
+2 -2
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicons.svg" /> <link rel="icon" type="image/svg+xml" href="/favicons.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XinAdmin</title> <title>XinAdmin</title>
<script type="module" crossorigin src="/assets/index-nfGZXE-D.js"></script> <script type="module" crossorigin src="/assets/index-CA-P_7Vi.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js"> <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js"> <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js"> <link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
@@ -92,7 +92,7 @@
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js"> <link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js"> <link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js"> <link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
<link rel="stylesheet" crossorigin href="/assets/index-SAHugrKB.css"> <link rel="stylesheet" crossorigin href="/assets/index-9KKK8arf.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+57
View File
@@ -72,6 +72,63 @@ class BillPaymentTest extends ProcurementTestCase
], $attributes)); ], $attributes));
} }
/** 生成账单:按门店保存售后说明与备注(可选,不参与金额计算) */
public function test_generate_bill_saves_after_sale_and_remark(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
$this->actingAsMiniStore($store);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 4]]])
->assertJsonPath('success', true);
$order = StoreOrderModel::where('store_id', $store->id)->first();
$this->actingAsSysUser();
$this->putJson("/order/store/{$order->id}/status", ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$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);
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [[
'store_id' => $store->id,
'delivery_fee' => 0,
'box_num' => 0,
'tray_num' => 0,
'after_sale' => '白菜烂叶 2 斤已协商',
'remark' => '下次配送顺带回收筐',
]],
])->assertJsonPath('success', true);
$bill = BillModel::where('store_id', $store->id)->first();
$this->assertSame('白菜烂叶 2 斤已协商', $bill->after_sale);
$this->assertSame('下次配送顺带回收筐', $bill->remark);
$this->assertSame('20.00', (string) $bill->total_amount, '售后/备注不影响金额');
// 不传售后/备注时默认空字符串
$store2 = StoreModel::factory()->create(['level_id' => $level->id]);
$this->actingAsMiniStore($store2);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$order2 = StoreOrderModel::where('store_id', $store2->id)->first();
$this->actingAsSysUser();
$this->putJson("/order/store/{$order2->id}/status", ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase2 = PurchaseOrderModel::latest('id')->first();
$this->putJson("/purchase/order/{$purchase2->id}/finish")->assertJsonPath('success', true);
$this->postJson("/purchase/order/{$purchase2->id}/bill", [
'stores' => [['store_id' => $store2->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0]],
])->assertJsonPath('success', true);
$bill2 = BillModel::where('store_id', $store2->id)->first();
$this->assertSame('', (string) $bill2->after_sale);
$this->assertSame('', (string) $bill2->remark);
}
/** 订单状态随业务链自动推进:接单→采购中→配送中→(生成账单)已完成 */ /** 订单状态随业务链自动推进:接单→采购中→配送中→(生成账单)已完成 */
public function test_order_status_progresses_via_business_chain(): void public function test_order_status_progresses_via_business_chain(): void
{ {
+68
View File
@@ -265,6 +265,74 @@ class CartTest extends ProcurementTestCase
$this->getJson('/mini/cart')->assertStatus(401); $this->getJson('/mini/cart')->assertStatus(401);
} }
/** 悬浮球汇总:种数/总数量/总金额(下架商品不计入数量与金额但仍计种数) */
public function test_cart_summary_endpoint(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']);
$this->actingAsMiniStore($store);
// 空购物车返回零值结构
$this->getJson('/mini/cart/summary')->assertOk()
->assertJsonPath('data.total_count', 0)
->assertJsonPath('data.total_quantity', '0.00')
->assertJsonPath('data.total_amount', '0.00');
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 2]);
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 1]);
$this->getJson('/mini/cart/summary')->assertOk()
->assertJsonPath('data.total_count', 2)
->assertJsonPath('data.total_quantity', '3.00')
->assertJsonPath('data.total_amount', '13.00');
// 下架商品:数量/金额不计入(与购物车列表口径一致),种数仍计
$p2->update(['status' => ProductModel::STATUS_OFF]);
$this->getJson('/mini/cart/summary')->assertOk()
->assertJsonPath('data.total_count', 2)
->assertJsonPath('data.total_quantity', '2.00')
->assertJsonPath('data.total_amount', '10.00');
}
/** 未登录访问悬浮球汇总 → 401 */
public function test_cart_summary_requires_login(): void
{
$this->getJson('/mini/cart/summary')->assertStatus(401);
}
/** 下单成功后自动清空购物车中已下单的商品(未下单商品保留) */
public function test_place_order_clears_ordered_items_from_cart(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']);
$this->actingAsMiniStore($store);
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 2]);
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 1]);
$this->assertSame(2, CartModel::where('store_id', $store->id)->count());
// 下单两个商品 → 购物车清空
$this->postJson('/mini/order', ['items' => [
['product_id' => $p1->id, 'quantity' => 2],
['product_id' => $p2->id, 'quantity' => 1],
]])->assertOk()->assertJsonPath('success', true);
$this->assertSame(0, CartModel::where('store_id', $store->id)->count(), '下单后购物车应自动清空');
// 部分下单:仅清除已下单商品,未下单商品保留
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 2]);
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 1]);
$this->postJson('/mini/order', ['items' => [['product_id' => $p1->id, 'quantity' => 2]]])
->assertOk()->assertJsonPath('success', true);
$remaining = CartModel::where('store_id', $store->id)->get();
$this->assertSame(1, $remaining->count(), '未下单商品应保留在购物车');
$this->assertSame($p2->id, $remaining->first()->product_id);
}
/** 跨门店列表隔离:B 店看不到 A 店的购物车 */ /** 跨门店列表隔离:B 店看不到 A 店的购物车 */
public function test_cart_isolated_between_stores(): void public function test_cart_isolated_between_stores(): void
{ {
+85 -19
View File
@@ -137,7 +137,7 @@ class ExportTest extends ProcurementTestCase
return [PurchaseOrderModel::first(), $veg, $meat, $storeA, $storeB, $supplierA, $supplierB]; return [PurchaseOrderModel::first(), $veg, $meat, $storeA, $storeB, $supplierA, $supplierB];
} }
/** 商品明细导出:含系统全部商品行(无订货数量 0)+ 行列合计 + 参考零售价列 */ /** 商品明细导出:含系统全部商品行(无订货数量 0)+ 行列合计 + 价列 */
public function test_export_lists_all_catalog_products_with_totals_row(): void public function test_export_lists_all_catalog_products_with_totals_row(): void
{ {
[$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers(); [$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers();
@@ -156,12 +156,12 @@ class ExportTest extends ProcurementTestCase
if ($rows->count() !== 8) { if ($rows->count() !== 8) {
return false; return false;
} }
// 列头含市场/参考零售价与门店列(市场列在供应商后,门店列从索引 12 起) // 列头含市场/价与门店列(市场列在供应商后,门店列从索引 12 起)
$header = $rows[3]; $header = $rows[3];
if ($header[4] !== '市场' || $header[8] !== '参考零售价' || $header[12] !== $storeA->name || $header[13] !== $storeB->name) { if ($header[4] !== '市场' || $header[8] !== '价' || $header[12] !== $storeA->name || $header[13] !== $storeB->name) {
return false; return false;
} }
// 蔬菜行:市场 新发地、数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3 // 蔬菜行:市场 新发地、数量 2+3=5、金额 25、价 5÷10=0.50、门店列 2/3
$vegRow = $rows->firstWhere(2, $veg->name); $vegRow = $rows->firstWhere(2, $veg->name);
if ($vegRow === null if ($vegRow === null
|| $vegRow[4] !== '新发地' || $vegRow[4] !== '新发地'
@@ -170,7 +170,7 @@ class ExportTest extends ProcurementTestCase
|| (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) { || (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) {
return false; return false;
} }
// 无订货商品行:数量 0、参考零售价留空、门店列 0 // 无订货商品行:数量 0、价留空、门店列 0
$extraRow = $rows->firstWhere(2, $extra->name); $extraRow = $rows->firstWhere(2, $extra->name);
if ($extraRow === null if ($extraRow === null
|| (float) $extraRow[9] !== 0.0 || $extraRow[8] !== '' || (float) $extraRow[9] !== 0.0 || $extraRow[8] !== ''
@@ -277,10 +277,10 @@ class ExportTest extends ProcurementTestCase
->assertJsonPath('msg', '该门店在此采购单中无采购商品'); ->assertJsonPath('msg', '该门店在此采购单中无采购商品');
} }
/** 供应商采购明细导出:供应商合并一个 XLSX(工作表名=供应商名),按供应商聚合 */ /** 供应商采购明细导出:按「供应商×市场」拆分工作表,模板列序=品名/汇总/市场/各门店明细 */
public function test_export_suppliers_multi_sheet(): void public function test_export_suppliers_multi_sheet(): void
{ {
[$purchase, $veg, $meat, , , $supplierA, $supplierB] = $this->buildPurchaseWithSuppliers(); [$purchase, $veg, $meat, $storeA, $storeB, $supplierA, $supplierB] = $this->buildPurchaseWithSuppliers();
Excel::fake(); Excel::fake();
$this->actingAsSysUser(); $this->actingAsSysUser();
@@ -288,37 +288,103 @@ class ExportTest extends ProcurementTestCase
Excel::assertDownloaded( Excel::assertDownloaded(
$purchase->purchase_no . '_供应商采购明细.xlsx', $purchase->purchase_no . '_供应商采购明细.xlsx',
static function (PurchaseSupplierExport $export) use ($supplierA, $supplierB, $veg, $meat): bool { static function (PurchaseSupplierExport $export) use ($supplierA, $supplierB, $veg, $meat, $storeA, $storeB): bool {
$sheets = $export->sheets(); $sheets = $export->sheets();
if (count($sheets) !== 2) { if (count($sheets) !== 2) {
return false; return false;
} }
$titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets);
if ($titles !== [$supplierA->name, $supplierB->name]) { if ($titles !== [$supplierA->name . '·新发地', $supplierB->name . '·岳各庄']) {
return false; return false;
} }
// 供应商甲:蔬菜 2+3=5 件、金额 5×5=25(市场列在品名后) // 供应商甲·新发地:蔬菜 2+3=5 件;门店列=有该供应商明细的两家门店
$rowsA = $sheets[0]->collection()->values(); $rowsA = $sheets[0]->collection()->values();
if ($rowsA[2] !== ['序号', '品名', '市场', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) { if ($rowsA[2] !== ['品名', '汇总', '市场', $storeA->name, $storeB->name]) {
return false; return false;
} }
$vegRow = $rowsA->firstWhere(1, $veg->name); $vegRow = $rowsA->firstWhere(0, $veg->name);
if ($vegRow === null || $vegRow[2] !== '新发地' || (int) $vegRow[6] !== 5 || (float) $vegRow[8] !== 25.0) { if ($vegRow === null || (int) $vegRow[1] !== 5 || $vegRow[2] !== '新发地'
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
return false; return false;
} }
$totalA = $rowsA->last(); $totalA = $rowsA->last();
if ($totalA[1] !== '合计' || (int) $totalA[6] !== 5 || (float) $totalA[8] !== 25.0) { if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) {
return false; return false;
} }
// 供应商乙:肉 1 件、金额 20 // 供应商乙·岳各庄:肉 1 件;门店列仅门店A
$rowsB = $sheets[1]->collection()->values(); $rowsB = $sheets[1]->collection()->values();
$meatRow = $rowsB->firstWhere(1, $meat->name); if ($rowsB[2] !== ['品名', '汇总', '市场', $storeA->name]) {
return $meatRow !== null && $meatRow[2] === '岳各庄' && (int) $meatRow[6] === 1 && (float) $meatRow[8] === 20.0; return false;
}
$meatRow = $rowsB->firstWhere(0, $meat->name);
return $meatRow !== null && (int) $meatRow[1] === 1 && $meatRow[2] === '岳各庄' && (int) $meatRow[3] === 1;
} }
); );
} }
/** 供应商采购明细导出:单供应商导出 + 无明细供应商拒绝 */ /** 供应商采购明细导出:同一供应商多个市场拆分为多个工作表(空市场归入「未设置」) */
public function test_export_suppliers_split_by_market(): void
{
$supplier = SupplierModel::factory()->create();
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$vegA = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00',
'supplier_id' => $supplier->id, 'market' => '新发地',
]);
$vegB = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON, 'cost_price' => '6.00',
'supplier_id' => $supplier->id, 'market' => '岳各庄',
]);
$vegC = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON, 'cost_price' => '7.00',
'supplier_id' => $supplier->id, 'market' => '',
]);
$this->actingAsMiniStore($store);
$this->postJson('/mini/order', ['items' => [
['product_id' => $vegA->id, 'quantity' => 1],
['product_id' => $vegB->id, 'quantity' => 1],
['product_id' => $vegC->id, 'quantity' => 1],
]])->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();
Excel::fake();
$this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplier->id}")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_供应商采购明细_' . $supplier->name . '.xlsx',
static function (PurchaseSupplierExport $export) use ($supplier, $vegA, $vegB, $vegC): bool {
$sheets = $export->sheets();
$titles = array_map(static fn ($sheet) => $sheet->title(), $sheets);
sort($titles);
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
return false;
}
// 每个工作表仅含本市场商品
foreach ($sheets as $sheet) {
$rows = $sheet->collection()->values();
$names = $rows->slice(3, -1)->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],
default => [$vegC->name],
};
if ($names !== $expected) {
return false;
}
}
return true;
}
);
}
/** 供应商采购明细导出:单供应商导出(工作表名=供应商·市场) + 无明细供应商拒绝 */
public function test_export_suppliers_single(): void public function test_export_suppliers_single(): void
{ {
[$purchase, , , , , , $supplierB] = $this->buildPurchaseWithSuppliers(); [$purchase, , , , , , $supplierB] = $this->buildPurchaseWithSuppliers();
@@ -331,7 +397,7 @@ class ExportTest extends ProcurementTestCase
$purchase->purchase_no . '_供应商采购明细_' . $supplierB->name . '.xlsx', $purchase->purchase_no . '_供应商采购明细_' . $supplierB->name . '.xlsx',
static function (PurchaseSupplierExport $export) use ($supplierB): bool { static function (PurchaseSupplierExport $export) use ($supplierB): bool {
$sheets = $export->sheets(); $sheets = $export->sheets();
return count($sheets) === 1 && $sheets[0]->title() === $supplierB->name; return count($sheets) === 1 && $sheets[0]->title() === $supplierB->name . '·岳各庄';
} }
); );
} }
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace Tests\Feature;
use App\Models\BillModel;
use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Services\WangpuPayService;
use Illuminate\Support\Facades\Http;
/**
* 小程序在线支付(旺铺网关 JSAPI):下单 调起支付 后台通知/主动查询结账
*
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
*/
class MiniOnlinePaymentTest extends ProcurementTestCase
{
private const string SIGN_KEY = 'test-wangpu-sign-key';
protected function setUp(): void
{
parent::setUp();
config([
'services.wechat.mini.appid' => 'wx-mini-test',
'services.wechat.mini.secret' => 'wx-secret-test',
'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.sign_key' => self::SIGN_KEY,
'services.wangpu.payway_code' => 'WECHAT_MINI',
]);
}
/** 模拟微信 code2session + 旺铺统一下单均成功 */
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
'https://wangpu.test/industrial/payment/order' => Http::response([
'code' => '0000',
'msg' => '调用成功',
'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid],
], 200),
]);
}
/** 造一张指定金额的未支付账单(总额=商品金额) */
private function makeBill(StoreModel $store, string $amount, array $attributes = []): BillModel
{
return BillModel::create(array_merge([
'bill_no' => 'ZD' . random_int(100000000000, 999999999999),
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
'store_id' => $store->id,
'bill_date' => '2026-08-27',
'product_amount' => $amount,
'delivery_fee' => '0.00',
'box_num' => 0,
'tray_num' => 0,
'box_price' => '0.00',
'tray_price' => '0.00',
'added_amount' => '0.00',
'total_amount' => $amount,
'status' => BillModel::STATUS_UNPAID,
], $attributes));
}
/** 造一笔待支付的在线支付单并锁定账单 */
private function makeOnlinePayment(StoreModel $store, string $amount, BillModel ...$bills): PaymentModel
{
$payment = PaymentModel::create([
'payment_no' => 'ZF' . now()->format('Ymd') . random_int(1000, 9999),
'store_id' => $store->id,
'amount' => $amount,
'pay_type' => PaymentModel::TYPE_ONLINE,
'pay_method' => PaymentModel::METHOD_WANGPU,
'voucher_ids' => '',
'status' => PaymentModel::STATUS_PENDING,
'openid' => 'oOpenidTest001',
]);
foreach ($bills as $bill) {
$bill->update(['payment_id' => $payment->id]);
}
return $payment;
}
/** 构造已加签的支付成功通知报文 */
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
{
$params = array_merge([
'mer_order_id' => $payment->payment_no,
'order_status' => '1',
'order_amt' => (string) $payment->amount,
'trade_no' => 'T20260827001',
'order_id' => 'WP202608270001',
'order_time' => '2026-08-27 09:59:00',
'trade_time' => '2026-08-27 10:00:00',
'payway_code' => 'WECHAT_MINI',
'mer_no' => 'mer001',
'device_no' => 'dev001',
'order_title' => '账单合并付款',
], $overrides);
$params['sign'] = app(WangpuPayService::class)->sign($params);
return $params;
}
/** 旺铺加签算法:按接口文档示例 golden testMD5 升序拼接 + key */
public function test_sign_matches_document_example(): void
{
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
$sign = app(WangpuPayService::class)->sign([
'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_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',
]);
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
}
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
public function test_create_online_payment_success(): void
{
$this->fakeGatewaySuccess();
$store = StoreModel::factory()->create();
$bill1 = $this->makeBill($store, '100.00');
$bill2 = $this->makeBill($store, '50.50');
$this->actingAsMiniStore($store);
$response = $this->postJson('/mini/payment/online', [
'bill_ids' => [$bill1->id, $bill2->id],
'code' => 'wx-login-code',
])->assertJsonPath('success', true);
$paymentNo = $response->json('data.payment_no');
$this->assertSame('150.50', $response->json('data.amount'));
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
$this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method);
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->status);
$this->assertSame('oOpenidTest001', $payment->openid);
$this->assertSame('WP202608270001', $payment->order_id);
// 账单锁定 + openid 绑定门店
$this->assertSame($payment->id, $bill1->fresh()->payment_id);
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
// 上送网关的报文:商户订单号=支付单号、金额、openid、带签名
Http::assertSent(static function ($request) use ($paymentNo) {
$body = $request->data();
return str_contains($request->url(), '/industrial/payment/order')
&& ($body['mer_order_id'] ?? '') === $paymentNo
&& ($body['order_amt'] ?? '') === '150.50'
&& ($body['open_id'] ?? '') === 'oOpenidTest001'
&& ($body['sub_appid'] ?? '') === 'wx-mini-test'
&& ! empty($body['sign'])
&& ! empty($body['notifyurl']);
});
}
/** 发起支付校验:缺 code / 非本店账单 / 已支付账单 / 锁定中账单 均拒绝 */
public function test_create_online_payment_validation(): void
{
$this->fakeGatewaySuccess();
$store = StoreModel::factory()->create();
$other = StoreModel::factory()->create();
$this->actingAsMiniStore($store);
// 缺 code
$this->postJson('/mini/payment/online', ['bill_ids' => [1]])->assertJsonPath('success', false);
// 非本店账单
$otherBill = $this->makeBill($other, '10.00');
$this->postJson('/mini/payment/online', ['bill_ids' => [$otherBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 已支付账单
$paidBill = $this->makeBill($store, '10.00', ['status' => BillModel::STATUS_PAID]);
$this->postJson('/mini/payment/online', ['bill_ids' => [$paidBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 锁定中账单(已在其他支付单)
$lockedBill = $this->makeBill($store, '10.00');
$this->makeOnlinePayment($store, '10.00', $lockedBill);
$this->postJson('/mini/payment/online', ['bill_ids' => [$lockedBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 均未产生新的待支付在线支付单(除锁定用那笔)
$this->assertSame(1, PaymentModel::where('pay_type', PaymentModel::TYPE_ONLINE)->count());
}
/** 微信 code2session 失败:报错且不产生支付单 */
public function test_create_fails_when_code2session_fails(): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200),
]);
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '20.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'bad-code'])
->assertJsonPath('success', false);
$this->assertSame(0, PaymentModel::count());
$this->assertSame(0, $bill->fresh()->payment_id);
}
/** 网关下单失败:支付单作废(置失败)并释放账单,可重新发起 */
public function test_create_gateway_failure_releases_bills(): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200),
'https://wangpu.test/*' => Http::response(['code' => '9999', 'msg' => '商户号不存在'], 200),
]);
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '20.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
->assertJsonPath('success', false);
$payment = PaymentModel::first();
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
}
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
public function test_notify_settles_payment(): void
{
$store = StoreModel::factory()->create();
$bill1 = $this->makeBill($store, '100.00');
$bill2 = $this->makeBill($store, '50.00');
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$payment->refresh();
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status);
$this->assertSame('T20260827001', $payment->trade_no);
$this->assertSame('WP202608270001', $payment->order_id);
$this->assertSame('2026-08-27 10:00:00', (string) $payment->paid_at);
foreach ([$bill1, $bill2] as $bill) {
$bill->refresh();
$this->assertSame(BillModel::STATUS_PAID, $bill->status);
$this->assertStringContainsString($payment->payment_no, (string) $bill->pay_remark);
}
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
// 门店收到支付成功通知
$this->assertTrue(
NoticeModel::where('store_id', $store->id)->where('title', '账单支付成功')->exists()
);
// 重复通知幂等:仍应答成功,金额不重复累加
$this->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);
// 验签失败
$badSign = $this->signedNotifyParams($payment);
$badSign['sign'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
// 金额不一致(防篡改)
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
->assertJsonPath('code', '01');
// 订单号不存在
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
->assertJsonPath('code', '01');
// 非支付成功状态
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
->assertJsonPath('code', '01');
// 均未结账
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->fresh()->status);
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
$this->assertSame('0.00', (string) $store->fresh()->total_purchase_amount);
}
/** 主动查询:网关已支付则同步结账;未支付保持待支付 */
public function test_query_syncs_gateway_status(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '80.00');
$payment = $this->makeOnlinePayment($store, '80.00', $bill);
// 第 1 次查询网关未支付,第 2 次已支付(fake 回调按调用次数返回,避免重复注册被先注册的 stub 拦截)
$queryCount = 0;
Http::fake([
'https://wangpu.test/industrial/query/order' => function () use (&$queryCount, $payment) {
$queryCount++;
$data = $queryCount === 1
? ['order_status' => 0, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00']
: [
'order_status' => 1,
'mer_order_id' => $payment->payment_no,
'order_amt' => '80.00',
'trade_no' => 'T20260827002',
'order_id' => 'WP202608270002',
'trade_time' => '2026-08-27 11:00:00',
];
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
},
]);
// 场景一:网关未支付
$this->actingAsMiniStore($store);
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_PENDING);
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
// 场景二:网关已支付 → 查询即结账
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED)
->assertJsonPath('data.trade_no', 'T20260827002');
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
// 已结账后重复查询不再请求网关(本地直接返回)
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED);
$this->assertSame(2, $queryCount, '已结账后不再请求网关');
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
}
/** 查询接口隔离:他人支付单不可见 */
public function test_query_rejects_other_store_payment(): void
{
$store = StoreModel::factory()->create();
$other = StoreModel::factory()->create();
$bill = $this->makeBill($other, '10.00');
$payment = $this->makeOnlinePayment($other, '10.00', $bill);
$this->actingAsMiniStore($store);
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', false);
}
/** 现有线下凭证支付流程不受影响:默认 pay_type=1 */
public function test_offline_payment_flow_unaffected(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '30.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment', [
'bill_ids' => [$bill->id],
'pay_method' => PaymentModel::METHOD_BANK,
'voucher_ids' => [1],
])->assertJsonPath('success', true);
$payment = PaymentModel::first();
$this->assertSame(PaymentModel::TYPE_OFFLINE, $payment->pay_type, '线下凭证支付默认 pay_type=1');
$this->assertSame($payment->id, $bill->fresh()->payment_id);
}
}
+82
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\CartModel;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\StoreModel; use App\Models\StoreModel;
@@ -103,4 +104,85 @@ class MiniProductTest extends ProcurementTestCase
$this->assertNotNull($row); $this->assertNotNull($row);
$this->assertNull($row['price']); $this->assertNull($row['price']);
} }
/** 商品列表:登录门店附加购物车行ID与数量,响应附悬浮球汇总 */
public function test_product_list_appends_cart_quantity_and_summary(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']);
$this->actingAsMiniStore($store);
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 2]);
$this->postJson('/mini/cart', ['product_id' => $extra->id, 'quantity' => 1]);
$cartId = CartModel::where('product_id', $product->id)->first()->id;
$data = $this->getJson('/mini/product/list')->assertOk()->json('data');
$row = collect($data['data'])->firstWhere('id', $product->id);
$this->assertSame($cartId, $row['cart_id'], '在购物车的商品应附购物车行ID');
$this->assertSame('2.00', $row['cart_quantity']);
// 不在购物车的商品:cart_id=0、cart_quantity='0.00'
$other = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '1.00']);
$otherRow = collect($this->getJson('/mini/product/list')->json('data.data'))->firstWhere('id', $other->id);
$this->assertSame(0, $otherRow['cart_id']);
$this->assertSame('0.00', $otherRow['cart_quantity']);
// 悬浮球汇总:2 种商品、总数量 3、总金额 5×2+3×1=13
$this->assertSame(2, $data['cart']['total_count']);
$this->assertSame('3.00', $data['cart']['total_quantity']);
$this->assertSame('13.00', $data['cart']['total_amount']);
}
/** 商品列表/详情:未登录时购物车字段为零值结构 */
public function test_product_list_guest_sees_zero_cart_fields(): void
{
[, $product] = $this->makeStoreWithProduct();
$data = $this->getJson('/mini/product/list')->assertOk()->json('data');
$row = collect($data['data'])->firstWhere('id', $product->id);
$this->assertSame(0, $row['cart_id']);
$this->assertSame('0.00', $row['cart_quantity']);
$this->assertSame(
['total_count' => 0, 'total_quantity' => '0.00', 'total_amount' => '0.00'],
$data['cart']
);
$detail = $this->getJson("/mini/product/{$product->id}")->assertOk()->json('data');
$this->assertSame(0, $detail['cart_id']);
$this->assertSame('0.00', $detail['cart_quantity']);
}
/** 商品详情:登录门店附加购物车行ID与数量 */
public function test_product_detail_appends_cart_quantity(): void
{
[$store, $product] = $this->makeStoreWithProduct();
$this->actingAsMiniStore($store);
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 4]);
$cartId = CartModel::first()->id;
$data = $this->getJson("/mini/product/{$product->id}")->assertOk()->json('data');
$this->assertSame($cartId, $data['cart_id']);
$this->assertSame('4.00', $data['cart_quantity']);
}
/** 首页:登录门店附购物车悬浮球汇总,未登录为零值结构 */
public function test_home_appends_cart_summary(): void
{
[$store, $product] = $this->makeStoreWithProduct('5.00');
// 未登录:零值结构
$guest = $this->getJson('/mini/home')->assertOk()->json('data');
$this->assertSame(
['total_count' => 0, 'total_quantity' => '0.00', 'total_amount' => '0.00'],
$guest['cart']
);
// 登录:汇总跟随购物车
$this->actingAsMiniStore($store);
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 3]);
$data = $this->getJson('/mini/home')->assertOk()->json('data');
$this->assertSame(1, $data['cart']['total_count']);
$this->assertSame('3.00', $data['cart']['total_quantity']);
$this->assertSame('15.00', $data['cart']['total_amount']);
}
} }
+41
View File
@@ -108,6 +108,47 @@ class ProductCostPriceTest extends ProcurementTestCase
$this->assertSame('10.00', (string) $product->cost_price); $this->assertSame('10.00', (string) $product->cost_price);
} }
/** 单价单位:不传默认「元/斤」,创建/编辑可修改 */
public function test_product_price_unit_default_and_editable(): void
{
$this->actingAsSysUser();
$category = $this->makeCategory();
// 不传单价单位 → 默认「元/斤」
$this->postJson('/product/goods', [
'category_id' => $category->id,
'name' => '默认单价单位商品',
'content' => '测试图文详情',
])->assertOk()->assertJsonPath('success', true);
$product = ProductModel::where('name', '默认单价单位商品')->first();
$this->assertSame('元/斤', $product->price_unit);
// 创建时指定 + 编辑修改
$this->postJson('/product/goods', [
'category_id' => $category->id,
'name' => '箱装商品',
'content' => '测试图文详情',
'price_unit' => '元/箱',
])->assertOk()->assertJsonPath('success', true);
$product2 = ProductModel::where('name', '箱装商品')->first();
$this->assertSame('元/箱', $product2->price_unit);
$this->putJson("/product/goods/{$product2->id}", [
'category_id' => $category->id,
'name' => '箱装商品',
'price_unit' => '元/件',
])->assertOk()->assertJsonPath('success', true);
$this->assertSame('元/件', $product2->refresh()->price_unit);
// 超长拒绝
$this->postJson('/product/goods', [
'category_id' => $category->id,
'name' => '异常单价单位商品',
'content' => '测试图文详情',
'price_unit' => str_repeat('元', 21),
])->assertOk()->assertJsonPath('success', false);
}
/** 批量调价-成本价行:更新成本并通知门店;等级售价随上浮比例联动 */ /** 批量调价-成本价行:更新成本并通知门店;等级售价随上浮比例联动 */
public function test_batch_price_cost_row_update(): void public function test_batch_price_cost_row_update(): void
{ {
+48
View File
@@ -271,4 +271,52 @@ class StoreOrderItemTest extends ProcurementTestCase
$this->getJson('/order/store?product_name=' . urlencode('不存在的商品')) $this->getJson('/order/store?product_name=' . urlencode('不存在的商品'))
->assertOk()->assertJsonPath('data.total', 0); ->assertOk()->assertJsonPath('data.total', 0);
} }
/** 后台订单列表按订货日期区间筛选(快捷按钮下发 Y-m-d 起止日期) */
public function test_admin_order_list_filters_by_order_date_range(): void
{
[$store, $product] = $this->makeStoreWithProduct();
$this->placeOrder($product, $store, 1);
$this->placeOrder($product, $store, 1);
$this->placeOrder($product, $store, 1);
$orders = StoreOrderModel::where('store_id', $store->id)->orderBy('id')->get();
$orders[0]->update(['order_date' => '2026-08-01']);
$orders[1]->update(['order_date' => '2026-08-05']);
$orders[2]->update(['order_date' => '2026-08-10']);
$this->actingAsSysUser();
$this->getJson('/order/store?order_date[]=2026-08-05&order_date[]=2026-08-10')
->assertOk()->assertJsonPath('data.total', 2);
$this->getJson('/order/store?order_date[]=2026-08-05&order_date[]=2026-08-05')
->assertOk()->assertJsonPath('data.total', 1, '今天/昨天等单日快捷按钮起止同日');
$this->getJson('/order/store?order_date[]=2026-08-11&order_date[]=2026-08-12')
->assertOk()->assertJsonPath('data.total', 0);
}
/** 后台订单列表按采购单号模糊搜索 */
public function test_admin_order_list_search_by_purchase_no(): void
{
[$store, $product] = $this->makeStoreWithProduct();
$this->placeOrder($product, $store, 1, StoreOrderModel::STATUS_SUMMARIZED);
$purchase = $this->generatePurchase();
// 生成采购单外的另一笔订单(不应被命中)
$this->placeOrder($product, $store, 1);
$this->actingAsSysUser();
$response = $this->getJson('/order/store?purchase_no=' . $purchase->purchase_no);
$response->assertOk()->assertJsonPath('success', true);
$this->assertSame(1, $response->json('data.total'));
$this->assertSame($purchase->purchase_no, $response->json('data.data.0.purchase.purchase_no'));
// 单号片段模糊命中
$fragment = substr($purchase->purchase_no, -6);
$this->getJson('/order/store?purchase_no=' . $fragment)
->assertOk()->assertJsonPath('data.total', 1);
// 无匹配单号 → 空列表
$this->getJson('/order/store?purchase_no=NOT-EXIST-NO')
->assertOk()->assertJsonPath('data.total', 0);
}
} }
+73
View File
@@ -2,8 +2,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\BillModel;
use App\Models\CustomerLevelModel; use App\Models\CustomerLevelModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use Modules\SystemTool\Models\SysFileModel; use Modules\SystemTool\Models\SysFileModel;
@@ -112,6 +114,77 @@ class StoreOrderTest extends ProcurementTestCase
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $order->refresh()->status); $this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $order->refresh()->status);
} }
/** 造一笔指定账单日期/状态的门店账单(默认总额 100.00 未支付) */
private function makeStoreBill(StoreModel $store, string $billDate, 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' => $billDate,
'total_amount' => '100.00',
'status' => BillModel::STATUS_UNPAID,
], $attributes));
}
/** 回款周期校验:存在逾期未回款账单(含审核中)时禁止下单,结清后恢复 */
public function test_place_order_blocked_by_overdue_unpaid_bill(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->paymentCycle(7)->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
// 账单日 10 天前(应结算日=3 天前,已逾期)且未支付 → 拒绝
$this->makeStoreBill($store, now()->subDays(10)->toDateString());
$this->actingAsMiniStore($store);
$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('¥100.00', (string) $response->json('msg'));
$this->assertSame(0, StoreOrderModel::count(), '拦截时不应生成订单');
// 审核中(已提交付款凭证)同样拦截
$this->makeStoreBill($store, now()->subDays(10)->toDateString(), ['payment_id' => 999]);
$blocked = $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
$blocked->assertJsonPath('success', false);
$this->assertStringContainsString('2 笔', (string) $blocked->json('msg'));
// 全部结清后恢复下单
BillModel::where('store_id', $store->id)->update(['status' => BillModel::STATUS_PAID]);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
}
/** 回款周期校验:周期内账单与到期日当天均不拦截;回款周期 0 天=任何未回款账单(含当天)都拦截 */
public function test_place_order_allowed_within_payment_cycle(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->paymentCycle(7)->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
$this->actingAsMiniStore($store);
// 账单日 7 天前 → 应结算日=今天,尚未超过周期,允许下单
$this->makeStoreBill($store, now()->subDays(7)->toDateString());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
// 回款周期 0 天:昨天的未回款账单拦截;当天的未回款账单同样拦截(立即结清口径)
$store->update(['payment_cycle_days' => 0]);
$this->makeStoreBill($store, now()->subDays(1)->toDateString());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', false);
BillModel::where('store_id', $store->id)->delete();
$this->makeStoreBill($store, now()->toDateString());
$response = $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
$response->assertJsonPath('success', false);
$this->assertStringContainsString('当日结清', (string) $response->json('msg'));
// 0 天周期下无未回款账单 → 正常下单
BillModel::where('store_id', $store->id)->update(['status' => BillModel::STATUS_PAID]);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
}
/** 门店数据隔离:只能查看本店订单 */ /** 门店数据隔离:只能查看本店订单 */
public function test_order_data_isolated_between_stores(): void public function test_order_data_isolated_between_stores(): void
{ {
+5 -1
View File
@@ -25,12 +25,16 @@ export interface PurchaseCellUpdateParams {
weight?: number; weight?: number;
} }
/** 生成账单:单个门店的配送费/周转筐/托盘数量 */ /** 生成账单:单个门店的配送费/周转筐/托盘数量与售后/备注 */
export interface BillGenerateStoreParams { export interface BillGenerateStoreParams {
store_id: number; store_id: number;
delivery_fee: number; delivery_fee: number;
box_num: number; box_num: number;
tray_num: number; tray_num: number;
/** 售后说明(文本,仅记录) */
after_sale?: string;
/** 备注 */
remark?: string;
} }
+17 -4
View File
@@ -17,6 +17,7 @@ import type {XinTableProps, XinTableInstance, RequestParams, FormMode} from "./t
import SearchForm from "./SearchForm"; import SearchForm from "./SearchForm";
import XinForm, {type XinFormRef} from "@/components/XinForm"; import XinForm, {type XinFormRef} from "@/components/XinForm";
import {type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react"; import {type ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
import dayjs from "dayjs";
import type {FormColumn} from "@/components/XinFormField/FieldRender/typings"; import type {FormColumn} from "@/components/XinFormField/FieldRender/typings";
import {Delete, List, Create, Update} from "@/api/common/table.ts"; import {Delete, List, Create, Update} from "@/api/common/table.ts";
import {isArray, isEmpty, omit} from "lodash"; import {isArray, isEmpty, omit} from "lodash";
@@ -60,6 +61,7 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
editShow = true, editShow = true,
deleteShow = true, deleteShow = true,
searchShow = true, searchShow = true,
searchDefaultOpen = false,
operateShow = true, operateShow = true,
paginationShow = true, paginationShow = true,
keywordSearchShow = true, keywordSearchShow = true,
@@ -89,7 +91,7 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
const [searchRef] = Form.useForm<T>(); const [searchRef] = Form.useForm<T>();
const [columnsChecked, setColumnsChecked] = useState<any[]>([]); const [columnsChecked, setColumnsChecked] = useState<any[]>([]);
const [searchRender, setSearchRender] = useState<boolean>(false); const [searchRender, setSearchRender] = useState<boolean>(searchDefaultOpen);
// 表单模式状态 // 表单模式状态
const [formMode, setFormMode] = useState<FormMode>('create'); const [formMode, setFormMode] = useState<FormMode>('create');
@@ -219,9 +221,17 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
/** 搜索表单提交 */ /** 搜索表单提交 */
const handleSearch = async () => { const handleSearch = async () => {
const searchValues: T = searchRef.getFieldsValue(); const searchValues: T = searchRef.getFieldsValue();
const dateSearch: any = {}
// 移除 空值 // 移除 空值
Object.keys(searchValues).forEach((key) => { Object.keys(searchValues).forEach((key) => {
if (searchValues[key] === '' || searchValues[key] === undefined) { const value = searchValues[key];
if (value === '' || value === undefined || value === null) {
delete searchValues[key];
} else if (dayjs.isDayjs(value)) {
dateSearch[key] = value.format('YYYY-MM-DD');
delete searchValues[key];
} else if (isArray(value) && value.length > 0 && value.every((item) => dayjs.isDayjs(item))) {
dateSearch[key] = value.map((item) => item.format('YYYY-MM-DD'));
delete searchValues[key]; delete searchValues[key];
} }
}); });
@@ -229,6 +239,7 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
page: 1, page: 1,
...requestParams, ...requestParams,
...searchValues, ...searchValues,
...dateSearch,
}); });
}; };
@@ -256,9 +267,11 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
.map(column => omit(column, ['hideInTable', 'hideInForm', 'hideInSearch', 'search'])); .map(column => omit(column, ['hideInTable', 'hideInForm', 'hideInSearch', 'search']));
}, [columns]); }, [columns]);
/** 初始化列设置树数据 */ /** 初始化列设置树数据defaultHiddenInTable 的列默认不勾选,可在列设置中手动开启) */
useEffect(() => { useEffect(() => {
const dataIndexList = defaultTableColumns.map(item => item.dataIndex!); const dataIndexList = defaultTableColumns
.filter(item => !item.defaultHiddenInTable)
.map(item => item.dataIndex!);
setColumnsChecked(dataIndexList); setColumnsChecked(dataIndexList);
}, [defaultTableColumns]); }, [defaultTableColumns]);
+4
View File
@@ -22,6 +22,8 @@ export type XinTableColumn<T = any> = Omit<TableColumnType<T>, 'dataIndex'> & {
hideInSearch?: boolean; hideInSearch?: boolean;
hideInForm?: boolean; hideInForm?: boolean;
hideInTable?: boolean; hideInTable?: boolean;
/** 列设置中默认不勾选(表格默认隐藏,用户可在列设置中手动开启) */
defaultHiddenInTable?: boolean;
hideInUpdate?: boolean; hideInUpdate?: boolean;
hideInCreate?: boolean; hideInCreate?: boolean;
search?: FormColumn<T>; search?: FormColumn<T>;
@@ -127,6 +129,8 @@ export interface XinTableProps<T = any> extends Omit<TableProps<T>, 'columns' |
deleteShow?: boolean | ((record: T) => boolean); deleteShow?: boolean | ((record: T) => boolean);
/** 搜索栏显示 */ /** 搜索栏显示 */
searchShow?: boolean; searchShow?: boolean;
/** 搜索栏默认展开 */
searchDefaultOpen?: boolean;
/** 表格操作列显示 */ /** 表格操作列显示 */
operateShow?: boolean; operateShow?: boolean;
/** 分页显示 */ /** 分页显示 */
+2
View File
@@ -29,6 +29,8 @@ export default interface IProduct {
spec?: string; spec?: string;
/** 计价单位 */ /** 计价单位 */
unit?: string; unit?: string;
/** 单价单位(如:元/斤、元/箱) */
price_unit?: string;
/** 封面 */ /** 封面 */
image_ids?: string; image_ids?: string;
images_arr?: ISysFileInfo[]; images_arr?: ISysFileInfo[];
+3 -1
View File
@@ -25,7 +25,7 @@ export interface IPurchaseDetailRow {
quantity: number; quantity: number;
/** 合计实际称重 */ /** 合计实际称重 */
weight: number; weight: number;
/** 合计订货金额(Σ明细 amount;参考零售价 = amount÷quantity÷包规数值) */ /** 合计订货金额(Σ明细 amount;价 = amount÷quantity÷包规数值) */
amount: string; amount: string;
cells: Record<number, number>; cells: Record<number, number>;
} }
@@ -122,6 +122,8 @@ export interface IBill {
paid_operator_id?: number; paid_operator_id?: number;
order_count?: number; order_count?: number;
remark?: string; remark?: string;
/** 售后说明(生成账单时按门店填写) */
after_sale?: string;
created_at?: string; created_at?: string;
/** 门店账单列表/详情接口附带 */ /** 门店账单列表/详情接口附带 */
store?: { id: number; name: string; address?: string; contact?: string; phone?: string } | null; store?: { id: number; name: string; address?: string; contact?: string; phone?: string } | null;
+31 -5
View File
@@ -185,6 +185,7 @@ const StoreOrderPage: React.FC = () => {
dataIndex: 'items', dataIndex: 'items',
hideInForm: true, hideInForm: true,
hideInSearch: true, hideInSearch: true,
defaultHiddenInTable: true,
width: 550, width: 550,
render: (_, record) => { render: (_, record) => {
return ( return (
@@ -212,7 +213,7 @@ const StoreOrderPage: React.FC = () => {
hideInForm: true, hideInForm: true,
hideInTable: true, hideInTable: true,
fieldProps: { fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })), options: [{ label: '全部', value: '' }, ...stores.map((s) => ({ label: s.name, value: s.id }))],
showSearch: true, showSearch: true,
optionFilterProp: 'label', optionFilterProp: 'label',
}, },
@@ -243,6 +244,15 @@ const StoreOrderPage: React.FC = () => {
hideInForm: true, hideInForm: true,
hideInTable: true, hideInTable: true,
align: 'center', align: 'center',
fieldProps: {
presets: [
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '昨天', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '前天', value: [dayjs().subtract(2, 'day'), dayjs().subtract(2, 'day')] },
{ label: '本周', value: [dayjs().startOf('week'), dayjs().endOf('week')] },
{ label: '本月', value: [dayjs().startOf('month'), dayjs().endOf('month')] },
],
},
}, },
{ {
title: '附加信息', title: '附加信息',
@@ -281,17 +291,32 @@ const StoreOrderPage: React.FC = () => {
align: 'center', align: 'center',
hideInTable: true, hideInTable: true,
fieldProps: { fieldProps: {
options: Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({ options: [
value: Number(value), { label: '全部', value: '' },
label: item.text, ...Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
})), value: Number(value),
label: item.text,
})),
],
} }
}, },
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
hideInTable: true,
fieldProps: {
placeholder: '采购单号',
allowClear: true,
},
},
{ {
title: '采购单', title: '采购单',
dataIndex: 'purchase_id', dataIndex: 'purchase_id',
valueType: 'digit', valueType: 'digit',
hideInForm: true, hideInForm: true,
hideInSearch: true,
render: (_, record) => record.purchase ? ( render: (_, record) => record.purchase ? (
<Space orientation={'vertical'}> <Space orientation={'vertical'}>
<div> <div>
@@ -393,6 +418,7 @@ const StoreOrderPage: React.FC = () => {
tableRef, tableRef,
operateShow: false, operateShow: false,
formProps: false, formProps: false,
searchDefaultOpen: true,
rowSelection: { rowSelection: {
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys), onChange: (keys) => setSelectedRowKeys(keys),
+9
View File
@@ -387,6 +387,15 @@ const ProductGoodsPage: React.FC = () => {
initialValue: '斤', initialValue: '斤',
align: "center", align: "center",
}, },
{
title: '单价单位',
dataIndex: 'price_unit',
valueType: 'text',
hideInTable: true,
hideInSearch: true,
initialValue: '元/斤',
fieldProps: { placeholder: '如:元/斤、元/箱', maxLength: 20 },
},
{ {
title: '排序', title: '排序',
dataIndex: 'sort', dataIndex: 'sort',
+88 -27
View File
@@ -86,6 +86,7 @@ const PurchaseOrderPage: React.FC = () => {
// 详情抽屉 // 详情抽屉
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [detailSize, setDetailSize] = useState(1200);
const [detail, setDetail] = useState<IPurchaseDetail | null>(null); const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false); const [completing, setCompleting] = useState(false);
@@ -127,6 +128,8 @@ const PurchaseOrderPage: React.FC = () => {
// 供应商采购明细页签 // 供应商采购明细页签
const [supplierId, setSupplierId] = useState<number>(0); const [supplierId, setSupplierId] = useState<number>(0);
/** 市场筛选('' = 全部市场) */
const [marketFilter, setMarketFilter] = useState<string>('');
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细 // 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
const [itemExportOpen, setItemExportOpen] = useState(false); const [itemExportOpen, setItemExportOpen] = useState(false);
@@ -147,11 +150,23 @@ const PurchaseOrderPage: React.FC = () => {
return Array.from(map, ([id, name]) => ({ id, name })); return Array.from(map, ([id, name]) => ({ id, name }));
}, [detail?.items]); }, [detail?.items]);
/** 供应商采购明细行:按供应商过滤矩阵行,金额=数量×成本价(成本口径 */ /** 当前供应商采购单内出现的市场列表(市场筛选下拉选项 */
const supplierMarkets = useMemo(() => {
const markets = new Set<string>();
(detail?.items ?? []).forEach((row) => {
if (row.supplier_id === supplierId && row.market) {
markets.add(row.market);
}
});
return Array.from(markets);
}, [detail?.items, supplierId]);
/** 供应商采购明细行:按供应商+市场过滤矩阵行,金额=数量×成本价(成本口径) */
const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => { const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => {
if (!detail || supplierId <= 0) return []; if (!detail || supplierId <= 0) return [];
return detail.items return detail.items
.filter((row) => row.supplier_id === supplierId) .filter((row) => row.supplier_id === supplierId)
.filter((row) => marketFilter === '' || (row.market ?? '') === marketFilter)
.map((row) => ({ .map((row) => ({
product_id: row.product_id, product_id: row.product_id,
product_name: row.product_name, product_name: row.product_name,
@@ -163,7 +178,7 @@ const PurchaseOrderPage: React.FC = () => {
weight: String(row.weight), weight: String(row.weight),
amount: (row.quantity * Number(row.cost_price)).toFixed(2), amount: (row.quantity * Number(row.cost_price)).toFixed(2),
})); }));
}, [detail, supplierId]); }, [detail, supplierId, marketFilter]);
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读) // 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
const [billOpen, setBillOpen] = useState(false); const [billOpen, setBillOpen] = useState(false);
@@ -426,6 +441,8 @@ const PurchaseOrderPage: React.FC = () => {
delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0, delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0,
box_num: row.bill ? row.bill.box_num : 0, box_num: row.bill ? row.bill.box_num : 0,
tray_num: row.bill ? row.bill.tray_num : 0, tray_num: row.bill ? row.bill.tray_num : 0,
after_sale: row.bill?.after_sale ?? '',
remark: row.bill?.remark ?? '',
})), })),
}); });
} finally { } finally {
@@ -464,20 +481,22 @@ const PurchaseOrderPage: React.FC = () => {
} }
}; };
/** 明细矩阵列:品名/供应商/参考零售价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => { const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [ const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' }, { title: '品名', dataIndex: 'product_name', width: 160, align: 'center', fixed: 'left', ellipsis: true },
{ {
title: '供应商', title: '供应商',
dataIndex: 'supplier', dataIndex: 'supplier',
width: 110, width: 110,
align: 'center', align: 'center',
fixed: 'left',
render: (_, row) => row.supplier?.name ?? '-', render: (_, row) => row.supplier?.name ?? '-',
}, },
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' }, { title: '市场', fixed: 'left', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
{ {
title: '参考零售价', title: '价',
key: 'retail_price', key: 'retail_price',
width: 100, width: 100,
align: 'center', align: 'center',
@@ -492,6 +511,13 @@ const PurchaseOrderPage: React.FC = () => {
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' }, { title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' }, { title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` }, { title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` },
{
title: '合计数量',
key: 'total_quantity',
width: 90,
align: 'center',
render: (_, row) => <Text strong>{row.quantity}</Text>,
},
]; ];
const storeColumns = (detail?.stores ?? []).map((store) => ({ const storeColumns = (detail?.stores ?? []).map((store) => ({
@@ -510,13 +536,6 @@ const PurchaseOrderPage: React.FC = () => {
})); }));
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [ const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{
title: '合计数量',
key: 'total_quantity',
width: 90,
align: 'center',
render: (_, row) => <Text strong>{row.quantity}</Text>,
},
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
@@ -563,27 +582,28 @@ const PurchaseOrderPage: React.FC = () => {
<Table.Summary.Cell index={0} colSpan={7} align="center"> <Table.Summary.Cell index={0} colSpan={7} align="center">
<Text strong></Text> <Text strong></Text>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
{storeTotals.map((amount, index) => ( {storeTotals.map((amount, index) => (
<Table.Summary.Cell key={stores[index].id} index={7 + index} align="center"> <Table.Summary.Cell key={stores[index].id} index={8 + index} align="center">
<Text strong>¥{amount.toFixed(2)}</Text> <Text strong>¥{amount.toFixed(2)}</Text>
</Table.Summary.Cell> </Table.Summary.Cell>
))} ))}
<Table.Summary.Cell index={7 + stores.length} align="center">
<Text strong>{totalQuantity}</Text> <Table.Summary.Cell index={9 + stores.length} align="center">
</Table.Summary.Cell>
<Table.Summary.Cell index={8 + stores.length} align="center">
<Text strong>¥{totalAmount.toFixed(2)}</Text> <Text strong>¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell> </Table.Summary.Cell>
</Table.Summary.Row> </Table.Summary.Row>
); );
}; };
/** 门店购买详情列:商品/市场/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */ /** 门店购买详情列:商品/市场/购买价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [ const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' }, { title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' }, { title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
{ {
title: '参考零售价', title: '价',
key: 'retail_price', key: 'retail_price',
width: 130, width: 130,
align: 'center', align: 'center',
@@ -976,7 +996,10 @@ const PurchaseOrderPage: React.FC = () => {
title={detail ? `采购单 ${detail.purchase.purchase_no}` : '采购单详情'} title={detail ? `采购单 ${detail.purchase.purchase_no}` : '采购单详情'}
open={detailOpen} open={detailOpen}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
size={1200} size={detailSize}
resizable={{
onResize: (newSize) => setDetailSize(newSize),
}}
loading={detailLoading} loading={detailLoading}
> >
{detail && ( {detail && (
@@ -1082,13 +1105,26 @@ const PurchaseOrderPage: React.FC = () => {
<Text></Text> <Text></Text>
<Select <Select
value={supplierId || undefined} value={supplierId || undefined}
onChange={(value) => setSupplierId(value)} onChange={(value) => {
setSupplierId(value);
setMarketFilter('');
}}
placeholder="选择供应商" placeholder="选择供应商"
className="w-60!" className="w-60!"
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))} options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))}
/> />
<Text></Text>
<Select
value={marketFilter}
onChange={setMarketFilter}
className="w-40!"
options={[
{ value: '', label: '全部市场' },
...supplierMarkets.map((m) => ({ value: m, label: m })),
]}
/>
</div> </div>
<AuthButton auth="purchase.order.export"> <AuthButton auth="purchase.order.export">
<Button <Button
@@ -1166,7 +1202,7 @@ const PurchaseOrderPage: React.FC = () => {
columns={buildItemColumns()} columns={buildItemColumns()}
dataSource={detail.items} dataSource={detail.items}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }} scroll={{ x: 1200, y: 800 }}
summary={renderSummary} summary={renderSummary}
/> />
</> </>
@@ -1404,14 +1440,14 @@ const PurchaseOrderPage: React.FC = () => {
confirmLoading={billSaving} confirmLoading={billSaving}
okText="确认生成" okText="确认生成"
okButtonProps={{ disabled: billAllGenerated }} okButtonProps={{ disabled: billAllGenerated }}
width={960} width={1280}
destroyOnHidden destroyOnHidden
> >
<Spin spinning={billLoading}> <Spin spinning={billLoading}>
{billPrepare && ( {billPrepare && (
<> <>
<div className="py-2 text-gray-500"> <div className="py-2 text-gray-500">
/== /==/
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'} {billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div> </div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}> <Form form={billForm} layout="vertical" onFinish={handleBillSave}>
@@ -1424,6 +1460,8 @@ const PurchaseOrderPage: React.FC = () => {
<div className="w-32 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]?.box_price ?? '0.00'}/</div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_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-36 shrink-0 text-center"></div>
<div className="w-28 shrink-0 text-center"></div> <div className="w-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div> <div className="flex-1 text-center"></div>
</div> </div>
@@ -1482,6 +1520,26 @@ const PurchaseOrderPage: React.FC = () => {
placeholder="正压负回" placeholder="正压负回"
/> />
</Form.Item> </Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'after_sale']}
>
<Input
maxLength={255}
disabled={billed}
placeholder="售后说明(可选)"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']}
>
<Input
maxLength={255}
disabled={billed}
placeholder="备注(可选)"
/>
</Form.Item>
<div className="w-28 shrink-0 text-center"> <div className="w-28 shrink-0 text-center">
¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)} ¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)}
</div> </div>
@@ -1638,7 +1696,7 @@ const PurchaseOrderPage: React.FC = () => {
/> />
</Modal> </Modal>
{/* 供应商采购明细导出:当前供应商 / 全部供应商(工作表) */} {/* 供应商采购明细导出:当前供应商 / 全部供应商(按「供应商×市场」拆分工作表) */}
<Modal <Modal
title="导出供应商采购明细" title="导出供应商采购明细"
open={supplierExportOpen} open={supplierExportOpen}
@@ -1655,13 +1713,16 @@ const PurchaseOrderPage: React.FC = () => {
okText="导出" okText="导出"
destroyOnHidden destroyOnHidden
> >
<div className="py-2 text-gray-500">
XLSX ×
</div>
<Radio.Group <Radio.Group
className="py-2" className="py-2"
value={supplierExportScope} value={supplierExportScope}
onChange={(e) => setSupplierExportScope(e.target.value)} onChange={(e) => setSupplierExportScope(e.target.value)}
options={[ options={[
{ value: 'current', label: `当前供应商(${purchaseSuppliers.find((s) => s.id === supplierId)?.name ?? '-'}` }, { value: 'current', label: `当前供应商(${purchaseSuppliers.find((s) => s.id === supplierId)?.name ?? '-'}` },
{ value: 'all', label: '全部供应商(合并为一个 XLSX,每供应商一个工作表)' }, { value: 'all', label: '全部供应商(合并为一个 XLSX,每供应商×市场一个工作表)' },
]} ]}
/> />
</Modal> </Modal>
+3
View File
@@ -403,6 +403,9 @@ const BillPage: React.FC = () => {
{detail.bill.pay_remark ? ( {detail.bill.pay_remark ? (
<Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item> <Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item>
) : null} ) : null}
{detail.bill.after_sale ? (
<Descriptions.Item label="售后" span={3}>{detail.bill.after_sale}</Descriptions.Item>
) : null}
{detail.bill.remark ? ( {detail.bill.remark ? (
<Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item> <Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item>
) : null} ) : null}