Compare commits
48 Commits
2eb27127c9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3005646179 | |||
| edf5e42c38 | |||
| 58c733baa2 | |||
| 7de51391e4 | |||
| 379aeac2b3 | |||
| a636bd31b5 | |||
| b784210012 | |||
| 51ccaaf11a | |||
| 7a895c143e | |||
| f9eb6f7d32 | |||
| f8e81cb830 | |||
| 52f396c12e | |||
| b1bb40a088 | |||
| 12cbed411b | |||
| 364471a086 | |||
| 5c7a5829af | |||
| 17c1281073 | |||
| 968054b9ff | |||
| c33247f214 | |||
| 1e25459676 | |||
| cf1d3a6229 | |||
| 52e339cdd3 | |||
| c986c86ed5 | |||
| 1370926780 | |||
| d190bdaade | |||
| 95ea0714ad | |||
| 9c59f3bc99 | |||
| 6c809d893b | |||
| f8cc40a609 | |||
| d0ce897a0f | |||
| c3ec416a77 | |||
| c35dac0055 | |||
| 65eb8ef594 | |||
| f3e190f460 | |||
| 52dc6efadf | |||
| 513cc568a9 | |||
| 5d58075644 | |||
| 01e67e02b5 | |||
| e5f849ef4d | |||
| 1dfa084508 | |||
| 4fe4e985f6 | |||
| 22237a47e7 | |||
| cd7007bc49 | |||
| 52ab195432 | |||
| c1d490134e | |||
| 6d9f02d831 | |||
| 3dbb9205a4 | |||
| a06c45dc49 |
@@ -46,3 +46,36 @@ 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=
|
||||||
|
|
||||||
|
# 微信公众号(网页授权换 openid,公众号 H5 JSAPI 支付用;后台 系统设置→微信应用配置 优先)
|
||||||
|
WECHAT_MP_APPID=
|
||||||
|
WECHAT_MP_SECRET=
|
||||||
|
|
||||||
|
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
|
||||||
|
PAY_ONLINE_CHANNEL=wangpu
|
||||||
|
|
||||||
|
# 旺铺支付网关(也可在后台 系统设置→旺铺支付 中维护,后台配置优先)
|
||||||
|
WANGPU_BASE_URL=
|
||||||
|
WANGPU_ORGANIZ_NO=
|
||||||
|
WANGPU_MER_NO=
|
||||||
|
WANGPU_MER_CODE=
|
||||||
|
WANGPU_TERM_CODE=
|
||||||
|
WANGPU_PUBLIC_KEY=
|
||||||
|
WANGPU_PRIVATE_KEY=
|
||||||
|
WANGPU_SUB_APPID=
|
||||||
|
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
||||||
|
# 公众号场景支付方式代码(留空回退 WANGPU_PAYWAY_CODE)
|
||||||
|
WANGPU_MP_PAYWAY_CODE=
|
||||||
|
|
||||||
|
# 微信官方支付 APIv3(也可在后台 系统设置→微信官方支付 中维护,后台配置优先)
|
||||||
|
WXPAY_MCH_ID=
|
||||||
|
WXPAY_APPID=
|
||||||
|
WXPAY_PRIVATE_KEY=
|
||||||
|
WXPAY_CERTIFICATE=
|
||||||
|
WXPAY_SECRET_KEY=
|
||||||
|
WXPAY_PLATFORM_CERT=
|
||||||
|
WXPAY_PLATFORM_SERIAL=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -10,14 +10,26 @@ use App\Services\BillDetailService;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
|
||||||
* 表尾汇总商品金额/配送费/附加金额/总金额
|
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
|
||||||
*/
|
*/
|
||||||
class BillExport implements FromCollection, WithStyles
|
class BillExport implements FromCollection, WithStyles
|
||||||
{
|
{
|
||||||
|
/** 数量列填充色(浅黄) */
|
||||||
|
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||||
|
|
||||||
|
/** 金额列填充色(浅红) */
|
||||||
|
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||||
|
|
||||||
|
/** 金额格式:¥ + 两位小数 */
|
||||||
|
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||||
|
|
||||||
private ?Collection $rows = null;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
|
||||||
@@ -26,9 +38,15 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
|
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
|
||||||
private array $mergeRows = [];
|
private array $mergeRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */
|
||||||
|
private array $fillCells = [];
|
||||||
|
|
||||||
/** 列头所在行索引 */
|
/** 列头所在行索引 */
|
||||||
private int $headerRow = 1;
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store)
|
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store)
|
||||||
* @param int $categoryId 一级分类ID(0=全部)
|
* @param int $categoryId 一级分类ID(0=全部)
|
||||||
@@ -76,7 +94,7 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
->all();
|
->all();
|
||||||
$rows[] = ['账单号:' . $billNoText];
|
$rows[] = ['账单号:' . $billNoText];
|
||||||
$this->specialRows[++$rowIndex] = 'scope';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
|
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||||
$this->specialRows[++$rowIndex] = 'scope';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
// 空行
|
// 空行
|
||||||
@@ -104,6 +122,8 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
(float) $item['amount'],
|
(float) $item['amount'],
|
||||||
];
|
];
|
||||||
$rowIndex++;
|
$rowIndex++;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $item['quantity']];
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $item['amount']];
|
||||||
$totalQuantity += (int) $item['quantity'];
|
$totalQuantity += (int) $item['quantity'];
|
||||||
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
|
$totalWeight = bcadd($totalWeight, $item['weight'], 3);
|
||||||
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
||||||
@@ -112,37 +132,51 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
// 商品合计行
|
// 商品合计行
|
||||||
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
|
||||||
|
|
||||||
// 配送费/附加金额/总金额(账单级费用全额汇总,不受分类过滤影响)
|
// 配送费/附加金额/售后金额/总金额(账单级费用全额汇总,不受分类过滤影响)
|
||||||
$deliveryTotal = '0';
|
$deliveryTotal = '0';
|
||||||
$addedTotal = '0';
|
$addedTotal = '0';
|
||||||
|
$afterSaleTotal = '0';
|
||||||
$boxNum = 0;
|
$boxNum = 0;
|
||||||
$trayNum = 0;
|
$trayNum = 0;
|
||||||
foreach ($this->bills as $bill) {
|
foreach ($this->bills as $bill) {
|
||||||
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
|
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
|
||||||
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
|
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
|
||||||
|
$afterSaleTotal = bcadd($afterSaleTotal, (string) $bill->after_sale, 2);
|
||||||
$boxNum += (int) $bill->box_num;
|
$boxNum += (int) $bill->box_num;
|
||||||
$trayNum += (int) $bill->tray_num;
|
$trayNum += (int) $bill->tray_num;
|
||||||
}
|
}
|
||||||
$grandTotal = bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2);
|
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
|
||||||
|
|
||||||
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
|
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
|
||||||
|
|
||||||
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
|
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
|
||||||
|
|
||||||
$rows[] = ['总金额(商品金额+配送费+附加金额)', '', '', '', '', '', '', (float) $grandTotal];
|
$rows[] = ['售后金额合计(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal];
|
||||||
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal];
|
||||||
|
|
||||||
|
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
|
||||||
$this->specialRows[++$rowIndex] = 'grand';
|
$this->specialRows[++$rowIndex] = 'grand';
|
||||||
$this->mergeRows[] = $rowIndex;
|
$this->mergeRows[] = $rowIndex;
|
||||||
|
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头
|
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -162,6 +196,43 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
$sheet->mergeCells('A' . $row . ':G' . $row);
|
$sheet->mergeCells('A' . $row . ':G' . $row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中(合计行标签保持左对齐)
|
||||||
|
$sheet->getStyle('A1:H' . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
foreach ($this->mergeRows as $row) {
|
||||||
|
$sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格区域(列头 → 末行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':H' . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 金额类列(单价/金额)货币格式:¥ + 两位小数
|
||||||
|
foreach (['E', 'H'] as $column) {
|
||||||
|
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode(self::CURRENCY_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列头固定填色:数量列(F)浅黄、金额列(H)浅红
|
||||||
|
$this->fillCell($sheet, 'F' . $this->headerRow, self::QUANTITY_FILL);
|
||||||
|
$this->fillCell($sheet, 'H' . $this->headerRow, self::AMOUNT_FILL);
|
||||||
|
|
||||||
|
// 数据区条件填色:非 0 填色,0 无背景(金额含负值)
|
||||||
|
foreach ($this->fillCells as $cell) {
|
||||||
|
if ((float) $cell['value'] !== 0.0) {
|
||||||
|
$this->fillCell(
|
||||||
|
$sheet,
|
||||||
|
$cell['column'] . $cell['row'],
|
||||||
|
$cell['column'] === 'F' ? self::QUANTITY_FILL : self::AMOUNT_FILL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$styles = [];
|
$styles = [];
|
||||||
foreach ($this->specialRows as $row => $type) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
@@ -178,6 +249,18 @@ class BillExport implements FromCollection, WithStyles
|
|||||||
return $styles;
|
return $styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
|
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
|
||||||
* 仅保留根分类为所选分类的行
|
* 仅保留根分类为所选分类的行
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Collection;
|
|||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,14 +24,32 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
|||||||
*/
|
*/
|
||||||
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
|
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
|
||||||
{
|
{
|
||||||
|
/** 门店列填充色(浅蓝) */
|
||||||
|
private const string STORE_FILL = 'FFDDEBF7';
|
||||||
|
|
||||||
|
/** 合计列填充色(浅红) */
|
||||||
|
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||||
|
|
||||||
|
/** 金额格式:¥ + 两位小数 */
|
||||||
|
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||||
|
|
||||||
private ?Collection $rows = null;
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
||||||
private array $specialRows = [];
|
private array $specialRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{stores: array<int, float>, total: float}> 日期行索引 => 填色判断数据 */
|
||||||
|
private array $rowData = [];
|
||||||
|
|
||||||
|
/** @var array{stores: array<int, float>, total: float} 合计行填色判断数据 */
|
||||||
|
private array $summaryTotals = ['stores' => [], 'total' => 0.0];
|
||||||
|
|
||||||
/** 列头所在行索引 */
|
/** 列头所在行索引 */
|
||||||
private int $headerRow = 1;
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
||||||
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
||||||
@@ -70,7 +92,7 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$this->specialRows[++$rowIndex] = 'title';
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
// 范围行:门店 / 导出时间
|
// 范围行:门店 / 导出时间
|
||||||
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
|
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||||
$this->specialRows[++$rowIndex] = 'scope';
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
// 空行
|
// 空行
|
||||||
@@ -90,16 +112,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$dateKey = $date->format('Y-m-d');
|
$dateKey = $date->format('Y-m-d');
|
||||||
$row = [$dateKey];
|
$row = [$dateKey];
|
||||||
$rowTotal = '0';
|
$rowTotal = '0';
|
||||||
|
$storeValues = [];
|
||||||
foreach ($storeIds as $storeId) {
|
foreach ($storeIds as $storeId) {
|
||||||
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
||||||
$row[] = (float) $amount;
|
$row[] = (float) $amount;
|
||||||
|
$storeValues[$storeId] = (float) $amount;
|
||||||
$rowTotal = bcadd($rowTotal, $amount, 2);
|
$rowTotal = bcadd($rowTotal, $amount, 2);
|
||||||
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
||||||
}
|
}
|
||||||
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
||||||
$row[] = (float) $rowTotal;
|
$row[] = (float) $rowTotal;
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
$rowIndex++;
|
$this->rowData[++$rowIndex] = ['stores' => $storeValues, 'total' => (float) $rowTotal];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合计行:各门店列合计 + 总计
|
// 合计行:各门店列合计 + 总计
|
||||||
@@ -110,12 +134,18 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$totalRow[] = (float) $grandTotal;
|
$totalRow[] = (float) $grandTotal;
|
||||||
$rows[] = $totalRow;
|
$rows[] = $totalRow;
|
||||||
$this->specialRows[++$rowIndex] = 'total';
|
$this->specialRows[++$rowIndex] = 'total';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = [
|
||||||
|
'stores' => array_map(floatval(...), $columnTotals),
|
||||||
|
'total' => (float) $grandTotal,
|
||||||
|
];
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标题/列头/合计行加粗,冻结列头行
|
* 标题/列头/合计行加粗;全表居中 + 全边框;金额区货币格式;
|
||||||
|
* 门店列(浅蓝)/合计列(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头行
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
@@ -128,6 +158,51 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
|
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$storeIds = array_map(intval(...), array_keys($this->storeNames));
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中
|
||||||
|
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
// 表格区域(列头 → 合计行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 金额区(门店列 + 合计列)货币格式:¥ + 两位小数
|
||||||
|
$sheet->getStyle('B' . ($this->headerRow + 1) . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode(self::CURRENCY_FORMAT);
|
||||||
|
|
||||||
|
// 门店列(B 起):非 0 浅蓝,0 无背景,列头固定浅蓝
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$column = Coordinate::stringFromColumnIndex(2 + $i);
|
||||||
|
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if (($data['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (($this->summaryTotals['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合计列(末列):非 0 浅红,0 无背景,列头固定浅红
|
||||||
|
$this->fillCell($sheet, $lastColumn . $this->headerRow, self::AMOUNT_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['total'] !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $lastColumn . $row, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['total'] !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $lastColumn . $this->lastRow, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
$styles = [];
|
$styles = [];
|
||||||
foreach ($this->specialRows as $row => $type) {
|
foreach ($this->specialRows as $row => $type) {
|
||||||
$style = match ($type) {
|
$style = match ($type) {
|
||||||
@@ -142,4 +217,16 @@ class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNul
|
|||||||
|
|
||||||
return $styles;
|
return $styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\ProductCategoryModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品档案导出:列格式与导入模板完全一致(首行即列头,导出文件修改后可直接重新导入)。
|
||||||
|
* 分类列输出「父分类/子分类」完整路径;状态列输出中文。
|
||||||
|
* template 模式仅输出列头 + 一行示例数据(首次导入前下载参考)。
|
||||||
|
*/
|
||||||
|
class ProductExport implements FromCollection, WithStrictNullComparison, WithStyles
|
||||||
|
{
|
||||||
|
/** 列头(与 ProductImport 的解析顺序一一对应,改动需同步) */
|
||||||
|
public const array HEADERS = ['品名', '分类', '供应商', '市场', '规格/包规', '单位', '成本价', '排序', '库存', '保质期', '状态', '备注'];
|
||||||
|
|
||||||
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $categoryId 末级分类筛选(0=全部商品)
|
||||||
|
* @param bool $template 仅输出列头 + 示例行(导入模板)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $categoryId = 0,
|
||||||
|
private readonly bool $template = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行结构:第 1 行列头,其后每行一件商品
|
||||||
|
*/
|
||||||
|
public function collection(): Collection
|
||||||
|
{
|
||||||
|
if ($this->rows !== null) {
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [self::HEADERS];
|
||||||
|
|
||||||
|
if ($this->template) {
|
||||||
|
$rows[] = ['西红柿', '蔬菜/茄果类', '示例供应商', '新发地', '10斤/箱', '斤', 2.5, 0, 100, 3, '上架', '示例行,导入前请删除'];
|
||||||
|
return $this->rows = collect($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = ProductModel::query()
|
||||||
|
->when($this->categoryId > 0, fn ($query) => $query->where('category_id', $this->categoryId))
|
||||||
|
->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->makeVisible('cost_price');
|
||||||
|
|
||||||
|
$categoryPaths = $this->categoryPaths($products->pluck('category_id')->unique()->all());
|
||||||
|
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id');
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$rows[] = [
|
||||||
|
$product->name,
|
||||||
|
$categoryPaths[(int) $product->category_id] ?? '',
|
||||||
|
$supplierNames[(int) $product->supplier_id] ?? '',
|
||||||
|
$product->market,
|
||||||
|
$product->spec,
|
||||||
|
$product->unit,
|
||||||
|
(float) $product->cost_price,
|
||||||
|
(int) $product->sort,
|
||||||
|
(int) $product->stock,
|
||||||
|
(int) $product->shelf_life,
|
||||||
|
(int) $product->status === ProductModel::STATUS_ON ? '上架' : '下架',
|
||||||
|
$product->remark,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rows = collect($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列头加粗、冻结首行、设置列宽
|
||||||
|
*/
|
||||||
|
public function styles(Worksheet $sheet): array
|
||||||
|
{
|
||||||
|
$this->collection();
|
||||||
|
|
||||||
|
$sheet->freezePane('A2');
|
||||||
|
$widths = [20, 16, 14, 12, 14, 8, 10, 8, 8, 8, 8, 24];
|
||||||
|
foreach ($widths as $index => $width) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
1 => ['font' => ['bold' => true]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类ID => 「父分类/子分类」完整路径(沿 parent_id 上溯拼接)
|
||||||
|
*
|
||||||
|
* @param array<int, int> $categoryIds
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function categoryPaths(array $categoryIds): array
|
||||||
|
{
|
||||||
|
$categories = ProductCategoryModel::all()->keyBy('id');
|
||||||
|
$paths = [];
|
||||||
|
foreach ($categoryIds as $categoryId) {
|
||||||
|
$names = [];
|
||||||
|
$cursor = (int) $categoryId;
|
||||||
|
$guard = 0;
|
||||||
|
while ($cursor > 0 && $guard++ < 20) {
|
||||||
|
$category = $categories->get($cursor);
|
||||||
|
if ($category === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
array_unshift($names, $category->name);
|
||||||
|
$cursor = (int) $category->parent_id;
|
||||||
|
}
|
||||||
|
$paths[(int) $categoryId] = implode('/', $names);
|
||||||
|
}
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
-100
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Exports;
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\ProductCategoryModel;
|
use App\Models\ProductCategoryModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
@@ -10,44 +11,74 @@ use App\Models\StoreOrderItemModel;
|
|||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单导出
|
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
|
||||||
|
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色;
|
||||||
|
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
|
||||||
|
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
|
||||||
*/
|
*/
|
||||||
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
|
||||||
{
|
{
|
||||||
/** @var array<int, string> 商品ID => 顶级分类名 */
|
/** 数量列填充色(浅黄) */
|
||||||
private array $categoryNames = [];
|
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||||
|
|
||||||
/** @var array<int, string> 供应商ID => 名称 */
|
/** 门店数量列填充色(浅蓝) */
|
||||||
private array $supplierNames = [];
|
private const string STORE_FILL = 'FFDDEBF7';
|
||||||
|
|
||||||
|
/** 金额列填充色(浅红) */
|
||||||
|
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||||
|
|
||||||
|
/** 金额格式:¥ + 两位小数 */
|
||||||
|
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||||
|
|
||||||
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary) */
|
||||||
|
private array $specialRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{quantity: float, amount: float, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
|
||||||
|
private array $rowData = [];
|
||||||
|
|
||||||
|
/** @var array{quantity: float, amount: float, stores: array<int, int>} 合计行填色判断数据 */
|
||||||
|
private array $summaryTotals = ['quantity' => 0.0, 'amount' => 0.0, 'stores' => []];
|
||||||
|
|
||||||
|
/** 列头所在行索引 */
|
||||||
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
/** @var array<int, string> 门店ID => 名称(导出列) */
|
/** @var array<int, string> 门店ID => 名称(导出列) */
|
||||||
private array $storeNames = [];
|
private array $storeNames = [];
|
||||||
|
|
||||||
private ?Collection $items = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PurchaseOrderModel $purchase 采购单
|
* @param PurchaseOrderModel $purchase 采购单
|
||||||
|
* @param int $supplierId 供应商筛选(0=全部)
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PurchaseOrderModel $purchase,
|
private readonly PurchaseOrderModel $purchase,
|
||||||
|
private readonly int $supplierId = 0,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出行(商品聚合行,按 分类sort → 商品sort 排序)
|
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings)
|
||||||
*/
|
*/
|
||||||
public function collection(): Collection
|
public function collection(): Collection
|
||||||
{
|
{
|
||||||
if ($this->items !== null) {
|
if ($this->rows !== null) {
|
||||||
return $this->items;
|
return $this->rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本采购单订货明细(关联订单过滤软删)
|
||||||
$orderItems = StoreOrderItemModel::query()
|
$orderItems = StoreOrderItemModel::query()
|
||||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||||
->where('store_order_item.purchase_id', $this->purchase->id)
|
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||||
@@ -61,135 +92,298 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
|||||||
->pluck('name', 'id')
|
->pluck('name', 'id')
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$products = ProductModel::withTrashed()
|
// 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准)
|
||||||
->with('category:id,sort')
|
$itemGroups = [];
|
||||||
->whereIn('id', $orderItems->pluck('product_id')->unique())
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
||||||
$first = $group->first();
|
$first = $group->first();
|
||||||
$product = $products->get((int) $productId);
|
|
||||||
$unitCost = $first->cost_price ?? '0';
|
|
||||||
|
|
||||||
$quantity = '0';
|
$quantity = '0';
|
||||||
$weight = '0';
|
$weight = '0';
|
||||||
$amount = '0';
|
$amount = '0';
|
||||||
$storeQuantities = array_fill_keys(array_keys($this->storeNames), '0');
|
$storeQuantities = array_fill_keys(array_keys($this->storeNames), 0);
|
||||||
foreach ($group as $item) {
|
foreach ($group as $item) {
|
||||||
$quantity = bcadd($quantity, (string) $item->quantity, 2);
|
$quantity = bcadd($quantity, (string) $item->quantity, 2);
|
||||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||||
// 金额 = 数量 × 每包成本价(单价/包规不参与金额计算)
|
// 金额 = 数量 × 每包成本价(单价/包规不参与金额计算)
|
||||||
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
|
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
|
||||||
$storeQuantities[(int) $item->store_id] = bcadd(
|
$storeQuantities[(int) $item->store_id] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity;
|
||||||
$storeQuantities[(int) $item->store_id] ?? '0',
|
}
|
||||||
(string) $item->quantity,
|
$itemGroups[(int) $productId] = [
|
||||||
2
|
'snapshot' => $first,
|
||||||
);
|
'quantity' => $quantity,
|
||||||
|
'weight' => $weight,
|
||||||
|
'amount' => $amount,
|
||||||
|
'store_quantities' => $storeQuantities,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows[] = [
|
// 导出范围:系统全部未删除商品(含下架)∪ 本采购单有订货的商品(含已删)
|
||||||
|
$products = ProductModel::withTrashed()
|
||||||
|
->with('category:id,sort')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orWhereIn('id', array_keys($itemGroups))
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
$categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all());
|
||||||
|
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray();
|
||||||
|
|
||||||
|
// 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id)
|
||||||
|
$items = [];
|
||||||
|
foreach ($products as $productId => $product) {
|
||||||
|
$group = $itemGroups[(int) $productId] ?? null;
|
||||||
|
$snapshot = $group['snapshot'] ?? null;
|
||||||
|
$rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id);
|
||||||
|
if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$quantity = $group['quantity'] ?? '0';
|
||||||
|
$amount = $group['amount'] ?? '0';
|
||||||
|
$spec = (string) ($snapshot->product_spec ?? $product->spec);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
'product_id' => (int) $productId,
|
'product_id' => (int) $productId,
|
||||||
'supplier_id' => (int) $first->supplier_id,
|
'category' => $categoryNames[(int) $productId] ?? '',
|
||||||
'product_name' => $first->product_name,
|
'product_name' => (string) ($snapshot->product_name ?? $product->name),
|
||||||
'product_spec' => $first->product_spec,
|
'supplier' => $supplierNames[$rowSupplierId] ?? '',
|
||||||
'unit' => $first->unit,
|
'market' => (string) ($product->market ?? ''),
|
||||||
'cost_price' => (float) ($first->cost_price ?? 0),
|
'product_spec' => $spec,
|
||||||
'unit_cost' => (float) $unitCost,
|
'unit' => (string) ($snapshot->unit ?? $product->unit),
|
||||||
|
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
|
||||||
|
// 单价 = 加权平均售价 ÷ 包规数值(无订货时按成本价换算,保证始终有单价)
|
||||||
|
'retail_price' => $group !== null && (float) $quantity > 0
|
||||||
|
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
|
||||||
|
: $this->unitRefPrice((float) ($snapshot->cost_price ?? $product->cost_price), $spec),
|
||||||
'quantity' => (float) $quantity,
|
'quantity' => (float) $quantity,
|
||||||
'weight' => (float) $weight,
|
'weight' => (float) ($group['weight'] ?? '0'),
|
||||||
'amount' => (float) $amount,
|
'amount' => (float) $amount,
|
||||||
'store_quantities' => array_map('floatval', $storeQuantities),
|
'store_quantities' => $group['store_quantities']
|
||||||
|
?? array_fill_keys(array_keys($this->storeNames), 0),
|
||||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||||
'product_sort' => (int) ($product->sort ?? 9999),
|
'product_sort' => (int) ($product->sort ?? 9999),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($rows, static fn (array $a, array $b): int =>
|
usort($items, static fn (array $a, array $b): int =>
|
||||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||||
|
|
||||||
$items = collect($rows);
|
if ($items === []) {
|
||||||
$this->loadLookups($items);
|
throw new RepositoryException('该供应商在系统中无商品行,无法导出');
|
||||||
|
|
||||||
$sort = 1;
|
|
||||||
return $this->items = $items->map(static function (array $row) use (&$sort): array {
|
|
||||||
$row['sort'] = $sort++;
|
|
||||||
return $row;
|
|
||||||
})->values();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function headings(): array
|
// ===== 手工建行 =====
|
||||||
{
|
$rows = [];
|
||||||
$this->collection();
|
$rowIndex = 0;
|
||||||
|
|
||||||
return array_merge(
|
// 标题行
|
||||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
$rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')'];
|
||||||
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
|
// 范围行:供应商筛选 / 导出时间
|
||||||
|
$scopeSupplier = '全部';
|
||||||
|
if ($this->supplierId > 0) {
|
||||||
|
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
|
||||||
|
}
|
||||||
|
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||||
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
|
// 空行
|
||||||
|
$rows[] = [''];
|
||||||
|
$rowIndex++;
|
||||||
|
|
||||||
|
// 列头
|
||||||
|
$rows[] = array_merge(
|
||||||
|
['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||||
array_values($this->storeNames),
|
array_values($this->storeNames),
|
||||||
);
|
);
|
||||||
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
|
// 明细行
|
||||||
|
$totalQuantity = '0';
|
||||||
|
$totalWeight = '0';
|
||||||
|
$totalAmount = '0';
|
||||||
|
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
|
||||||
|
foreach (array_values($items) as $sort => $item) {
|
||||||
|
$this->rowData[++$rowIndex] = [
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'amount' => $item['amount'],
|
||||||
|
'store_quantities' => $item['store_quantities'],
|
||||||
|
];
|
||||||
|
$rows[] = array_merge([
|
||||||
|
$sort + 1,
|
||||||
|
$item['category'],
|
||||||
|
$item['product_name'],
|
||||||
|
$item['supplier'],
|
||||||
|
$item['market'],
|
||||||
|
$item['product_spec'],
|
||||||
|
$item['unit'],
|
||||||
|
$item['cost_price'],
|
||||||
|
$item['retail_price'],
|
||||||
|
$item['quantity'],
|
||||||
|
$item['weight'],
|
||||||
|
$item['amount'],
|
||||||
|
], array_map(
|
||||||
|
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
|
||||||
|
array_values($item['store_quantities']),
|
||||||
|
));
|
||||||
|
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
|
||||||
|
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||||
|
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||||
|
foreach ($item['store_quantities'] as $storeId => $qty) {
|
||||||
|
$storeTotals[$storeId] += $qty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function map($row): array
|
// 合计行(行合计 + 门店列合计;门店无数据显示空白)
|
||||||
{
|
$rows[] = array_merge(
|
||||||
return array_merge([
|
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
||||||
$row['sort'],
|
array_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
|
||||||
$this->categoryNames[$row['product_id']] ?? '',
|
);
|
||||||
$row['product_name'],
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
$this->supplierNames[$row['supplier_id']] ?? '',
|
$this->lastRow = $rowIndex;
|
||||||
$row['product_spec'],
|
$this->summaryTotals = [
|
||||||
$row['unit'],
|
'quantity' => (float) $totalQuantity,
|
||||||
$row['cost_price'],
|
'amount' => (float) $totalAmount,
|
||||||
$row['unit_cost'],
|
'stores' => $storeTotals,
|
||||||
$row['quantity'],
|
];
|
||||||
$row['weight'],
|
|
||||||
$row['amount'],
|
return $this->rows = collect($rows);
|
||||||
], array_values($row['store_quantities']));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表头加粗 + 冻结首行
|
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
public function styles(Worksheet $sheet): array
|
||||||
{
|
{
|
||||||
$sheet->freezePane('A2');
|
$this->collection();
|
||||||
|
|
||||||
return [
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
1 => ['font' => ['bold' => true]],
|
// 市场/数量列宽减半
|
||||||
];
|
$widths = [6, 10, 20, 12, 6, 12, 8, 10, 12, 5, 12, 12];
|
||||||
|
foreach ($widths as $index => $width) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storeIds = array_keys($this->storeNames);
|
||||||
|
$storeCount = count($storeIds);
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认隐藏列:序号/分类/包规/单位/成本/单价/实际称重/金额(数据照常导出,Excel 中可取消隐藏)
|
||||||
|
foreach ([1, 2, 6, 7, 8, 9, 11, 12] as $hiddenIndex) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($hiddenIndex))->setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中
|
||||||
|
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
// 列头自动换行(市场/数量/门店列较窄,表头文字折行显示)
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setWrapText(true);
|
||||||
|
|
||||||
|
// 表格区域(列头 → 合计行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 金额类列(成本/单价/金额)货币格式:¥ + 两位小数
|
||||||
|
foreach (['H', 'I', 'L'] as $column) {
|
||||||
|
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode(self::CURRENCY_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数量列(J):有数据浅黄,0 无背景,列头固定浅黄
|
||||||
|
$this->fillCell($sheet, 'J' . $this->headerRow, self::QUANTITY_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['quantity'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'J' . $row, self::QUANTITY_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['quantity'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'J' . $this->lastRow, self::QUANTITY_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额列(L):有数据浅红,0 无背景,列头固定浅红
|
||||||
|
$this->fillCell($sheet, 'L' . $this->headerRow, self::AMOUNT_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['amount'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'L' . $row, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['amount'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'L' . $this->lastRow, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 门店数量列:有数据浅蓝,0 无背景,列头固定浅蓝
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$column = Coordinate::stringFromColumnIndex(13 + $i);
|
||||||
|
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
|
||||||
|
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
|
||||||
|
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$styles = [];
|
||||||
|
foreach ($this->specialRows as $row => $type) {
|
||||||
|
$style = match ($type) {
|
||||||
|
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||||
|
'header', 'summary' => ['font' => ['bold' => true]],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
if ($style !== []) {
|
||||||
|
$styles[$row] = $style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDF 模板视图数据
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考)
|
||||||
|
*/
|
||||||
|
private function unitRefPrice(float $price, string $spec): float
|
||||||
|
{
|
||||||
|
$pack = (float) preg_replace('/[^0-9.].*$/', '', $spec);
|
||||||
|
return $pack > 0 ? round($price / $pack, 2) : round($price, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品ID => 顶级分类名(沿 parent_id 上溯取根分类)
|
||||||
*
|
*
|
||||||
* @return array{purchase: PurchaseOrderModel, items: Collection, storeNames: array<int, string>, categoryNames: array<int, string>, supplierNames: array<int, string>}
|
* @param array<int, int> $productCategoryIds 商品ID => 分类ID
|
||||||
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public function viewData(): array
|
private function rootCategoryNames(array $productCategoryIds): array
|
||||||
{
|
{
|
||||||
return [
|
|
||||||
'purchase' => $this->purchase,
|
|
||||||
'items' => $this->collection(),
|
|
||||||
'storeNames' => $this->storeNames,
|
|
||||||
'categoryNames' => $this->categoryNames,
|
|
||||||
'supplierNames' => $this->supplierNames,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预加载供应商名与商品顶级分类名(商品/供应商均含软删除,保证历史单据可导出)
|
|
||||||
*/
|
|
||||||
private function loadLookups(Collection $items): void
|
|
||||||
{
|
|
||||||
$this->supplierNames = SupplierModel::withTrashed()
|
|
||||||
->whereIn('id', $items->pluck('supplier_id')->unique())
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
$productCategoryIds = ProductModel::withTrashed()
|
|
||||||
->whereIn('id', $items->pluck('product_id')->unique())
|
|
||||||
->pluck('category_id', 'id');
|
|
||||||
|
|
||||||
$categories = ProductCategoryModel::all()->keyBy('id');
|
$categories = ProductCategoryModel::all()->keyBy('id');
|
||||||
$this->categoryNames = [];
|
$names = [];
|
||||||
foreach ($productCategoryIds as $productId => $categoryId) {
|
foreach ($productCategoryIds as $productId => $categoryId) {
|
||||||
$rootName = '';
|
$rootName = '';
|
||||||
$cursor = (int) $categoryId;
|
$cursor = (int) $categoryId;
|
||||||
@@ -202,7 +396,8 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
|||||||
$rootName = $category->name;
|
$rootName = $category->name;
|
||||||
$cursor = (int) $category->parent_id;
|
$cursor = (int) $category->parent_id;
|
||||||
}
|
}
|
||||||
$this->categoryNames[(int) $productId] = $rootName;
|
$names[(int) $productId] = $rootName;
|
||||||
}
|
}
|
||||||
|
return $names;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
use App\Models\StoreOrderItemModel;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情导出:多门店合并为一个 XLSX(每门店一个工作表,工作表名=门店名称);
|
||||||
|
* 构造传入 storeId 时仅导出该门店(单工作表)
|
||||||
|
*/
|
||||||
|
class PurchaseStoreExport implements WithMultipleSheets
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PurchaseOrderModel $purchase 采购单
|
||||||
|
* @param int $storeId 单门店导出(0=全部门店)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly PurchaseOrderModel $purchase,
|
||||||
|
private readonly int $storeId = 0,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, PurchaseStoreSheet>
|
||||||
|
*/
|
||||||
|
public function sheets(): array
|
||||||
|
{
|
||||||
|
// 本采购单内有明细的门店(含软删除,保证历史单据可导出)
|
||||||
|
$query = StoreOrderItemModel::query()
|
||||||
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||||
|
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||||
|
->whereNull('store_order.deleted_at');
|
||||||
|
if ($this->storeId > 0) {
|
||||||
|
$query->where('store_order_item.store_id', $this->storeId);
|
||||||
|
}
|
||||||
|
$storeIds = $query->distinct()->pluck('store_order_item.store_id');
|
||||||
|
|
||||||
|
if ($storeIds->isEmpty()) {
|
||||||
|
throw new RepositoryException(
|
||||||
|
$this->storeId > 0 ? '该门店在此采购单中无采购商品' : '该采购单无门店采购明细,无法导出'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stores = StoreModel::withTrashed()
|
||||||
|
->whereIn('id', $storeIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
|
$usedNames = [];
|
||||||
|
$sheets = [];
|
||||||
|
foreach ($stores as $store) {
|
||||||
|
$sheets[] = new PurchaseStoreSheet(
|
||||||
|
$this->purchase,
|
||||||
|
$store,
|
||||||
|
SheetName::make((string) $store->name, (int) $store->id, $usedNames),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sheets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
use App\Services\PurchaseItemService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情导出 · 单门店工作表
|
||||||
|
*/
|
||||||
|
class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
||||||
|
{
|
||||||
|
/** 数量列填充色(浅黄) */
|
||||||
|
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||||
|
|
||||||
|
/** 金额列填充色(浅红) */
|
||||||
|
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||||
|
|
||||||
|
/** 金额格式:¥ + 两位小数 */
|
||||||
|
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||||
|
|
||||||
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary) */
|
||||||
|
private array $specialRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{quantity: int, amount: float}> 明细行索引 => 填色判断数据 */
|
||||||
|
private array $rowData = [];
|
||||||
|
|
||||||
|
/** @var array{quantity: int, amount: float} 合计行填色判断数据 */
|
||||||
|
private array $summaryTotals = ['quantity' => 0, 'amount' => 0.0];
|
||||||
|
|
||||||
|
/** 列头所在行索引 */
|
||||||
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PurchaseOrderModel $purchase,
|
||||||
|
private readonly StoreModel $store,
|
||||||
|
private readonly string $sheetTitle,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出行:标题/空行/列头/明细/合计
|
||||||
|
*/
|
||||||
|
public function collection(): Collection
|
||||||
|
{
|
||||||
|
if ($this->rows !== null) {
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = app(PurchaseItemService::class)->storeSummaryRows($this->purchase->id, $this->store->id);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$rowIndex = 0;
|
||||||
|
|
||||||
|
// 标题行
|
||||||
|
$rows[] = [$this->store->name . ' · 采购单 ' . $this->purchase->purchase_no];
|
||||||
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
|
// 空行
|
||||||
|
$rows[] = [''];
|
||||||
|
$rowIndex++;
|
||||||
|
|
||||||
|
// 列头
|
||||||
|
$rows[] = ['序号', '品名', '市场', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
|
||||||
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
|
// 明细行
|
||||||
|
$totalQuantity = 0;
|
||||||
|
$totalWeight = '0';
|
||||||
|
$totalAmount = '0';
|
||||||
|
foreach (array_values($items) as $sort => $item) {
|
||||||
|
$this->rowData[++$rowIndex] = [
|
||||||
|
'quantity' => (int) $item['quantity'],
|
||||||
|
'amount' => (float) $item['amount'],
|
||||||
|
];
|
||||||
|
$rows[] = [
|
||||||
|
$sort + 1,
|
||||||
|
$item['product_name'],
|
||||||
|
$item['market'],
|
||||||
|
$item['product_spec'],
|
||||||
|
$item['unit'],
|
||||||
|
(float) $item['price'],
|
||||||
|
(int) $item['quantity'],
|
||||||
|
(float) $item['weight'],
|
||||||
|
(float) $item['amount'],
|
||||||
|
];
|
||||||
|
$totalQuantity += (int) $item['quantity'];
|
||||||
|
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
|
||||||
|
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合计行
|
||||||
|
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = ['quantity' => $totalQuantity, 'amount' => (float) $totalAmount];
|
||||||
|
|
||||||
|
return $this->rows = collect($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return $this->sheetTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
|
*/
|
||||||
|
public function styles(Worksheet $sheet): array
|
||||||
|
{
|
||||||
|
$this->collection();
|
||||||
|
|
||||||
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
|
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
|
||||||
|
foreach ($widths as $index => $width) {
|
||||||
|
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
|
||||||
|
->setWidth($width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中
|
||||||
|
$sheet->getStyle('A1:I' . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
// 表格区域(列头 → 合计行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':I' . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 金额类列(单价/预计金额)货币格式:¥ + 两位小数
|
||||||
|
foreach (['F', 'I'] as $column) {
|
||||||
|
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode(self::CURRENCY_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数量列(G):有数据浅黄,0 无背景,列头固定浅黄
|
||||||
|
$this->fillCell($sheet, 'G' . $this->headerRow, self::QUANTITY_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['quantity'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'G' . $row, self::QUANTITY_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['quantity'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'G' . $this->lastRow, self::QUANTITY_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额列(I):有数据浅红,0 无背景,列头固定浅红
|
||||||
|
$this->fillCell($sheet, 'I' . $this->headerRow, self::AMOUNT_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['amount'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'I' . $row, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['amount'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'I' . $this->lastRow, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
$styles = [];
|
||||||
|
foreach ($this->specialRows as $row => $type) {
|
||||||
|
$style = match ($type) {
|
||||||
|
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||||
|
'header', 'summary' => ['font' => ['bold' => true]],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
if ($style !== []) {
|
||||||
|
$styles[$row] = $style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreOrderItemModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
|
use App\Services\PurchaseItemService;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供应商采购明细导出:多供应商合并为一个 XLSX,按「供应商 × 市场」拆分工作表
|
||||||
|
* (工作表名=供应商·市场,市场取商品档案,未设置市场归入「未设置」);
|
||||||
|
* 构造传入 supplierId 时仅导出该供应商
|
||||||
|
*/
|
||||||
|
class PurchaseSupplierExport implements WithMultipleSheets
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PurchaseOrderModel $purchase 采购单
|
||||||
|
* @param int $supplierId 单供应商导出(0=全部供应商)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly PurchaseOrderModel $purchase,
|
||||||
|
private readonly int $supplierId = 0,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, PurchaseSupplierSheet>
|
||||||
|
*/
|
||||||
|
public function sheets(): array
|
||||||
|
{
|
||||||
|
// 本采购单内有明细的供应商(按订货明细快照 supplier_id 归集)
|
||||||
|
$query = StoreOrderItemModel::query()
|
||||||
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||||
|
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||||
|
->whereNull('store_order.deleted_at')
|
||||||
|
->where('store_order_item.supplier_id', '>', 0);
|
||||||
|
if ($this->supplierId > 0) {
|
||||||
|
$query->where('store_order_item.supplier_id', $this->supplierId);
|
||||||
|
}
|
||||||
|
$supplierIds = $query->distinct()->pluck('store_order_item.supplier_id');
|
||||||
|
|
||||||
|
if ($supplierIds->isEmpty()) {
|
||||||
|
throw new RepositoryException(
|
||||||
|
$this->supplierId > 0 ? '该供应商在此采购单中无采购明细' : '该采购单无供应商采购明细,无法导出'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$suppliers = SupplierModel::withTrashed()
|
||||||
|
->whereIn('id', $supplierIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
|
$service = app(PurchaseItemService::class);
|
||||||
|
$usedNames = [];
|
||||||
|
$sheets = [];
|
||||||
|
foreach ($suppliers as $supplier) {
|
||||||
|
// 按市场拆分工作表
|
||||||
|
$matrix = $service->supplierMarketMatrix($this->purchase->id, (int) $supplier->id);
|
||||||
|
foreach ($matrix['markets'] as $market => $rows) {
|
||||||
|
$marketLabel = $market === '' ? '未设置' : $market;
|
||||||
|
$sheets[] = new PurchaseSupplierSheet(
|
||||||
|
$this->purchase,
|
||||||
|
$supplier,
|
||||||
|
$marketLabel,
|
||||||
|
$rows,
|
||||||
|
$matrix['stores'],
|
||||||
|
SheetName::make($supplier->name . '·' . $marketLabel, (int) $supplier->id, $usedNames),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sheets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供应商采购明细导出 · 单市场工作表
|
||||||
|
* 模板列序:品名、汇总、市场、各门店明细(门店列=该供应商有明细的门店);
|
||||||
|
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色)
|
||||||
|
*/
|
||||||
|
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
|
||||||
|
{
|
||||||
|
/** 汇总列填充色(浅黄) */
|
||||||
|
private const string QUANTITY_FILL = 'FFFFF2CC';
|
||||||
|
|
||||||
|
/** 门店数量列填充色(浅蓝) */
|
||||||
|
private const string STORE_FILL = 'FFDDEBF7';
|
||||||
|
|
||||||
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header) */
|
||||||
|
private array $specialRows = [];
|
||||||
|
|
||||||
|
/** 列头所在行索引 */
|
||||||
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
|
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
|
||||||
|
private array $rowData = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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(
|
||||||
|
private readonly PurchaseOrderModel $purchase,
|
||||||
|
private readonly SupplierModel $supplier,
|
||||||
|
private readonly string $marketLabel,
|
||||||
|
private readonly array $productRows,
|
||||||
|
private readonly array $stores,
|
||||||
|
private readonly string $sheetTitle,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出行:标题+备注(合并区)/空行(合并区)/列头(品名、汇总、市场、各门店)/明细
|
||||||
|
*/
|
||||||
|
public function collection(): Collection
|
||||||
|
{
|
||||||
|
if ($this->rows !== null) {
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storeIds = array_map('intval', array_keys($this->stores));
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$rowIndex = 0;
|
||||||
|
|
||||||
|
// 标题行(A1:C2 合并占两行),D1 起放采购单备注(D1:I2 合并,红色 22 号字)
|
||||||
|
$rows[] = [
|
||||||
|
$this->supplier->name . ' · ' . $this->marketLabel . ' · 采购单 ' . $this->purchase->purchase_no,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
trim((string) $this->purchase->remark),
|
||||||
|
];
|
||||||
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
|
// 空行(被标题/备注合并区域覆盖)
|
||||||
|
$rows[] = [''];
|
||||||
|
$rowIndex++;
|
||||||
|
|
||||||
|
// 列头:品名、汇总、市场、各门店明细
|
||||||
|
$rows[] = array_merge(['品名', '汇总', '市场'], array_values($this->stores));
|
||||||
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
|
// 明细行(0 数量的门店格留空不填色)
|
||||||
|
foreach ($this->productRows as $productRow) {
|
||||||
|
$line = [
|
||||||
|
$productRow['product_name'],
|
||||||
|
(int) $productRow['quantity'],
|
||||||
|
$this->marketLabel,
|
||||||
|
];
|
||||||
|
$storeQuantities = [];
|
||||||
|
foreach ($storeIds as $storeId) {
|
||||||
|
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
|
||||||
|
$line[] = $quantity > 0 ? $quantity : '';
|
||||||
|
$storeQuantities[$storeId] = $quantity;
|
||||||
|
}
|
||||||
|
$rows[] = $line;
|
||||||
|
$this->rowData[++$rowIndex] = [
|
||||||
|
'quantity' => (int) $productRow['quantity'],
|
||||||
|
'store_quantities' => $storeQuantities,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
|
||||||
|
return $this->rows = collect($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return $this->sheetTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题合并 A1:C2、备注合并 D1:I2(红色 22 号字);标题/列头加粗;全表居中 + 全边框 + 列头行自动换行;
|
||||||
|
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
|
*/
|
||||||
|
public function styles(Worksheet $sheet): array
|
||||||
|
{
|
||||||
|
$this->collection();
|
||||||
|
|
||||||
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
|
|
||||||
|
// 标题合并前三列占两行(A1:C2),备注合并六列两行(D1:I2)
|
||||||
|
$sheet->mergeCells('A1:C2');
|
||||||
|
$sheet->mergeCells('D1:I2');
|
||||||
|
|
||||||
|
$widths = [24, 10, 6];
|
||||||
|
foreach ($widths as $index => $width) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
|
}
|
||||||
|
$storeIds = array_map('intval', array_keys($this->stores));
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex(3 + max(count($storeIds), 1));
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(4 + $i))->setWidth(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中
|
||||||
|
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
// 列头行自动换行(门店列窄,门店名换行显示)
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setWrapText(true);
|
||||||
|
|
||||||
|
// 备注合并单元格自动换行(长备注多行显示)
|
||||||
|
$sheet->getStyle('D1')->getAlignment()->setWrapText(true);
|
||||||
|
|
||||||
|
// 表格区域(列头 → 数据末行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 汇总列(B):有数据浅黄,0 无背景,列头固定浅黄
|
||||||
|
$this->fillCell($sheet, 'B' . $this->headerRow, self::QUANTITY_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['quantity'] > 0) {
|
||||||
|
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$column = Coordinate::stringFromColumnIndex(4 + $i);
|
||||||
|
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
|
||||||
|
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$styles = [];
|
||||||
|
foreach ($this->specialRows as $row => $type) {
|
||||||
|
$style = match ($type) {
|
||||||
|
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||||
|
'header' => ['font' => ['bold' => true]],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
if ($style !== []) {
|
||||||
|
$styles[$row] = $style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备注单元格(D1):红色 22 号字,须放在行样式之后应用以覆盖标题行字号
|
||||||
|
$styles['D1'] = ['font' => ['bold' => true, 'size' => 22, 'color' => ['argb' => 'FFFF0000']]];
|
||||||
|
|
||||||
|
return $styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单元格纯色填充
|
||||||
|
*/
|
||||||
|
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
|
||||||
|
{
|
||||||
|
$sheet->getStyle($coordinate)
|
||||||
|
->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()
|
||||||
|
->setARGB($argb);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel 工作表名合法化:剥离非法字符、31 字截断、重名追加 #id
|
||||||
|
*/
|
||||||
|
final class SheetName
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $name 原始名称(门店名/供应商名)
|
||||||
|
* @param int $id 实体ID(重名时追加)
|
||||||
|
* @param array<int, string> $used 已用名列表(引用传入,调用方维护)
|
||||||
|
*/
|
||||||
|
public static function make(string $name, int $id, array &$used): string
|
||||||
|
{
|
||||||
|
$base = str_replace(['[', ']', ':', '*', '?', '/', '\\'], '', $name);
|
||||||
|
$base = mb_substr($base === '' ? '未命名' : $base, 0, 28);
|
||||||
|
$title = $base;
|
||||||
|
if (in_array($title, $used, true)) {
|
||||||
|
$title = mb_substr($base, 0, 25) . '#' . $id;
|
||||||
|
}
|
||||||
|
$used[] = $title;
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Client;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Http\Requests\Client\SpecialBatchAddRequest;
|
||||||
|
use App\Http\Requests\Client\SpecialFormRequest;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐商品配置(小程序首页)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/client/special', 'client.special')]
|
||||||
|
class SpecialController extends BaseController
|
||||||
|
{
|
||||||
|
protected array $searchField = [
|
||||||
|
'status' => '=',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 推荐列表(默认按排序升序;keyword 按商品名模糊搜索) */
|
||||||
|
#[GetRoute(authorize: 'query')]
|
||||||
|
public function query(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$params = $request->all();
|
||||||
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
|
$query = $this->buildSearch($params, HomeSpecialModel::query()->with('product'));
|
||||||
|
|
||||||
|
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->whereHas('product', static function ($q) use ($keyword) {
|
||||||
|
$q->where('name', 'like', '%' . $keyword . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $query->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize)
|
||||||
|
->toArray();
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量添加推荐商品(已在推荐中的自动跳过) */
|
||||||
|
#[PostRoute('/batch', 'create')]
|
||||||
|
public function batchAdd(SpecialBatchAddRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productIds = $request->validated()['product_ids'];
|
||||||
|
$exists = HomeSpecialModel::whereIn('product_id', $productIds)->pluck('product_id')->all();
|
||||||
|
$newIds = array_values(array_diff($productIds, $exists));
|
||||||
|
|
||||||
|
foreach ($newIds as $productId) {
|
||||||
|
HomeSpecialModel::create([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'added' => count($newIds),
|
||||||
|
'skipped' => count($productIds) - count($newIds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑推荐(仅排序与状态) */
|
||||||
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
|
public function update(int $id, SpecialFormRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->update($request->validated());
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除推荐 */
|
||||||
|
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||||
|
public function delete(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除推荐 */
|
||||||
|
#[DeleteRoute('/batch', 'delete')]
|
||||||
|
public function batchDelete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'ids' => 'required|array|min:1',
|
||||||
|
'ids.*' => 'integer|distinct',
|
||||||
|
], [
|
||||||
|
'ids.required' => '请选择要删除的推荐',
|
||||||
|
'ids.min' => '请选择要删除的推荐',
|
||||||
|
'ids.*.integer' => '推荐 ID 格式错误',
|
||||||
|
'ids.*.distinct' => '存在重复的推荐',
|
||||||
|
]);
|
||||||
|
HomeSpecialModel::whereIn('id', $data['ids'])->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ namespace App\Http\Controllers\Customer;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Http\Requests\Customer\CustomerLevelFormRequest;
|
use App\Http\Requests\Customer\CustomerLevelFormRequest;
|
||||||
use App\Models\CustomerLevelModel;
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\NoticeModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
@@ -17,7 +20,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
|||||||
use Modules\SystemTool\Services\SysFileService;
|
use Modules\SystemTool\Services\SysFileService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户等级管理(同一商品按客户等级定价)
|
* 客户等级管理(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/customer/level', 'customer.level')]
|
#[RequestAttribute('/customer/level', 'customer.level')]
|
||||||
class CustomerLevelController extends BaseController
|
class CustomerLevelController extends BaseController
|
||||||
@@ -63,7 +66,7 @@ class CustomerLevelController extends BaseController
|
|||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑等级 */
|
/** 编辑等级(上浮比例变更时通知该等级下门店用户) */
|
||||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
|
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -71,7 +74,14 @@ class CustomerLevelController extends BaseController
|
|||||||
if (empty($model)) {
|
if (empty($model)) {
|
||||||
throw new RepositoryException('客户等级不存在');
|
throw new RepositoryException('客户等级不存在');
|
||||||
}
|
}
|
||||||
$model->update($request->validated());
|
$validated = $request->validated();
|
||||||
|
DB::transaction(function () use ($model, $validated) {
|
||||||
|
$model->update($validated);
|
||||||
|
// 上浮比例变化 → 该等级下所有商品的售价联动变化,通知受影响门店用户
|
||||||
|
if ($model->wasChanged('percent')) {
|
||||||
|
$this->notifyPriceChange($model);
|
||||||
|
}
|
||||||
|
});
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +96,6 @@ class CustomerLevelController extends BaseController
|
|||||||
if ($model->stores()->exists()) {
|
if ($model->stores()->exists()) {
|
||||||
throw new RepositoryException('该等级下存在门店,无法删除');
|
throw new RepositoryException('该等级下存在门店,无法删除');
|
||||||
}
|
}
|
||||||
if ($model->prices()->exists()) {
|
|
||||||
throw new RepositoryException('该等级下存在商品价格,无法删除');
|
|
||||||
}
|
|
||||||
$model->delete();
|
$model->delete();
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
@@ -100,8 +107,36 @@ class CustomerLevelController extends BaseController
|
|||||||
$data = CustomerLevelModel::query()
|
$data = CustomerLevelModel::query()
|
||||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||||
->orderBy('sort')
|
->orderBy('sort')
|
||||||
->get(['id', 'name'])
|
->get(['id', 'name', 'percent'])
|
||||||
->toArray();
|
->toArray();
|
||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上浮比例变更后,给该等级下全部正常门店生成价格变更通知
|
||||||
|
*/
|
||||||
|
private function notifyPriceChange(CustomerLevelModel $level): void
|
||||||
|
{
|
||||||
|
$storeIds = StoreModel::query()
|
||||||
|
->where('status', StoreModel::STATUS_NORMAL)
|
||||||
|
->where('level_id', $level->id)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$content = mb_substr(
|
||||||
|
'您所在客户等级「' . $level->name . '」的价格上浮比例已调整为 ' . (float) $level->percent . '%,商品价格将按新比例显示',
|
||||||
|
0,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($storeIds as $storeId) {
|
||||||
|
NoticeModel::create([
|
||||||
|
'store_id' => $storeId,
|
||||||
|
'type' => NoticeModel::TYPE_PRICE,
|
||||||
|
'title' => '商品价格变更',
|
||||||
|
'content' => $content,
|
||||||
|
'data' => ['level_id' => $level->id],
|
||||||
|
'is_read' => NoticeModel::UNREAD,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Customer;
|
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
|
||||||
use App\Http\Requests\Customer\MiniUserBindRequest;
|
|
||||||
use App\Models\StoreModel;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 小程序用户管理(用户由小程序登录自动生成,后台只做绑定与状态管理,无增删)
|
|
||||||
*/
|
|
||||||
#[RequestAttribute('/customer/miniUser', 'customer.miniUser')]
|
|
||||||
class MiniUserController extends BaseController
|
|
||||||
{
|
|
||||||
protected array $searchField = [
|
|
||||||
'store_id' => '=',
|
|
||||||
'status' => '=',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected array $quickSearchField = ['nickname', 'phone'];
|
|
||||||
|
|
||||||
/** 小程序用户列表 */
|
|
||||||
#[GetRoute(authorize: 'query')]
|
|
||||||
public function query(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$params = $request->all();
|
|
||||||
$pageSize = $params['pageSize'] ?? 10;
|
|
||||||
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name'))
|
|
||||||
->orderBy('id', 'desc')
|
|
||||||
->paginate($pageSize)
|
|
||||||
->toArray();
|
|
||||||
return $this->success($data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定门店(一个门店可绑多个账号,一个账号只绑一个门店)
|
|
||||||
*/
|
|
||||||
#[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])]
|
|
||||||
public function bind(int $id, MiniUserBindRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validated();
|
|
||||||
$user = UserModel::find($id);
|
|
||||||
if (empty($user)) {
|
|
||||||
throw new RepositoryException('用户不存在');
|
|
||||||
}
|
|
||||||
$store = StoreModel::find((int) $validated['store_id']);
|
|
||||||
if (empty($store)) {
|
|
||||||
throw new RepositoryException('门店不存在');
|
|
||||||
}
|
|
||||||
$user->store_id = $store->id;
|
|
||||||
$user->save();
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启用/停用 */
|
|
||||||
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
|
|
||||||
public function status(int $id, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'status' => 'required|integer|in:0,1',
|
|
||||||
], [
|
|
||||||
'status.required' => '状态不能为空',
|
|
||||||
'status.in' => '状态值不正确',
|
|
||||||
]);
|
|
||||||
$user = UserModel::find($id);
|
|
||||||
if (empty($user)) {
|
|
||||||
throw new RepositoryException('用户不存在');
|
|
||||||
}
|
|
||||||
$user->status = (int) $data['status'];
|
|
||||||
$user->save();
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知管理(小程序端消息;user_id=0 为全员广播)
|
* 通知管理(小程序端消息;store_id=0 为全员广播)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/customer/notice', 'customer.notice')]
|
#[RequestAttribute('/customer/notice', 'customer.notice')]
|
||||||
class NoticeController extends BaseController
|
class NoticeController extends BaseController
|
||||||
@@ -22,7 +22,7 @@ class NoticeController extends BaseController
|
|||||||
protected array $searchField = [
|
protected array $searchField = [
|
||||||
'type' => '=',
|
'type' => '=',
|
||||||
'is_read' => '=',
|
'is_read' => '=',
|
||||||
'user_id' => '=',
|
'store_id' => '=',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $quickSearchField = ['title', 'content'];
|
protected array $quickSearchField = ['title', 'content'];
|
||||||
@@ -40,7 +40,7 @@ class NoticeController extends BaseController
|
|||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发布通知(user_id=0 全员广播) */
|
/** 发布通知(store_id=0 全员广播) */
|
||||||
#[PostRoute(authorize: 'create')]
|
#[PostRoute(authorize: 'create')]
|
||||||
public function create(NoticeFormRequest $request): JsonResponse
|
public function create(NoticeFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店管理(小程序下单主体,即客户)
|
* 门店管理(小程序下单主体,即客户;门店账号即小程序登录账号)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/customer/store', 'customer.store')]
|
#[RequestAttribute('/customer/store', 'customer.store')]
|
||||||
class StoreController extends BaseController
|
class StoreController extends BaseController
|
||||||
@@ -23,13 +23,14 @@ class StoreController extends BaseController
|
|||||||
protected array $searchField = [
|
protected array $searchField = [
|
||||||
'name' => 'like',
|
'name' => 'like',
|
||||||
'code' => 'like',
|
'code' => 'like',
|
||||||
|
'username' => 'like',
|
||||||
'level_id' => '=',
|
'level_id' => '=',
|
||||||
'status' => '=',
|
'status' => '=',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $quickSearchField = ['name', 'code', 'contact', 'phone'];
|
protected array $quickSearchField = ['name', 'code', 'username', 'contact', 'phone'];
|
||||||
|
|
||||||
/** 门店列表(含等级名回显) */
|
/** 门店列表(含等级名回显;密码永不回显) */
|
||||||
#[GetRoute(authorize: 'query')]
|
#[GetRoute(authorize: 'query')]
|
||||||
public function query(Request $request): JsonResponse
|
public function query(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -42,17 +43,18 @@ class StoreController extends BaseController
|
|||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建门店 */
|
/** 创建门店(同时设置小程序登录账号与初始密码) */
|
||||||
#[PostRoute(authorize: 'create')]
|
#[PostRoute(authorize: 'create')]
|
||||||
public function create(StoreFormRequest $request): JsonResponse
|
public function create(StoreFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$validated['code'] = generate_unique_code(StoreModel::class);
|
$validated['code'] = generate_unique_code(StoreModel::class);
|
||||||
|
$validated['password'] = password_hash((string) $validated['password'], PASSWORD_DEFAULT);
|
||||||
StoreModel::create($validated);
|
StoreModel::create($validated);
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑门店 */
|
/** 编辑门店(密码留空则不修改) */
|
||||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, StoreFormRequest $request): JsonResponse
|
public function update(int $id, StoreFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -60,7 +62,13 @@ class StoreController extends BaseController
|
|||||||
if (empty($model)) {
|
if (empty($model)) {
|
||||||
throw new RepositoryException('门店不存在');
|
throw new RepositoryException('门店不存在');
|
||||||
}
|
}
|
||||||
$model->update($request->validated());
|
$validated = $request->validated();
|
||||||
|
if (empty($validated['password'])) {
|
||||||
|
unset($validated['password']);
|
||||||
|
} else {
|
||||||
|
$validated['password'] = password_hash((string) $validated['password'], PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
$model->update($validated);
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\StoreModel;
|
|||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -349,9 +348,9 @@ class DashboardController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础档案计数:在营门店 / 在售商品 / 合作供应商 / 小程序用户
|
* 基础档案计数:在营门店 / 在售商品 / 合作供应商
|
||||||
*
|
*
|
||||||
* @return array{stores: int, products: int, suppliers: int, users: int}
|
* @return array{stores: int, products: int, suppliers: int}
|
||||||
*/
|
*/
|
||||||
private function archives(): array
|
private function archives(): array
|
||||||
{
|
{
|
||||||
@@ -359,7 +358,6 @@ class DashboardController extends BaseController
|
|||||||
'stores' => StoreModel::query()->where('status', StoreModel::STATUS_NORMAL)->count(),
|
'stores' => StoreModel::query()->where('status', StoreModel::STATUS_NORMAL)->count(),
|
||||||
'products' => ProductModel::query()->where('status', ProductModel::STATUS_ON)->count(),
|
'products' => ProductModel::query()->where('status', ProductModel::STATUS_ON)->count(),
|
||||||
'suppliers' => SupplierModel::query()->where('status', SupplierModel::STATUS_NORMAL)->count(),
|
'suppliers' => SupplierModel::query()->where('status', SupplierModel::STATUS_NORMAL)->count(),
|
||||||
'users' => UserModel::query()->where('status', UserModel::STATUS_NORMAL)->count(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\UserRegisterRequest;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Trait\RequestJson;
|
use Modules\Common\Trait\RequestJson;
|
||||||
|
|
||||||
@@ -17,7 +12,7 @@ class IndexController
|
|||||||
{
|
{
|
||||||
use RequestJson;
|
use RequestJson;
|
||||||
// 权限验证白名单
|
// 权限验证白名单
|
||||||
protected array $noPermission = ['index', 'login', 'register', 'mail'];
|
protected array $noPermission = ['index'];
|
||||||
|
|
||||||
/** 获取首页信息 */
|
/** 获取首页信息 */
|
||||||
#[GetRoute('/index')]
|
#[GetRoute('/index')]
|
||||||
@@ -27,37 +22,4 @@ class IndexController
|
|||||||
|
|
||||||
return $this->success(compact('web_setting'));
|
return $this->success(compact('web_setting'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户登录 */
|
|
||||||
#[PostRoute('/login')]
|
|
||||||
public function login(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$credentials = $request->validate([
|
|
||||||
'username' => 'required|min:4|alphaDash',
|
|
||||||
'password' => 'required|min:4|alphaDash',
|
|
||||||
]);
|
|
||||||
if (Auth::guard('users')->attempt($credentials, true)) {
|
|
||||||
$data = $request->user('users')
|
|
||||||
->createToken($credentials['username'])
|
|
||||||
->toArray();
|
|
||||||
return $this->success($data, __('user.login_success'));
|
|
||||||
}
|
|
||||||
return $this->error(__('user.login_error'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户注册 */
|
|
||||||
#[PostRoute('/register')]
|
|
||||||
public function register(UserRegisterRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$data = $request->validated();
|
|
||||||
$model = new UserModel;
|
|
||||||
$model->username = $data['username'];
|
|
||||||
$model->password = password_hash($data['password'], PASSWORD_DEFAULT);
|
|
||||||
$model->email = $data['email'];
|
|
||||||
if ($model->save()) {
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->error('创建用户失败');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Mini;
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Http\Requests\UserUpdateInfoRequest;
|
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Services\WechatService;
|
|
||||||
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;
|
||||||
@@ -15,113 +12,79 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
|||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序认证
|
* 小程序认证(门店 账号 + 密码 登录,不再使用微信能力)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
class AuthController extends BaseMiniController
|
class AuthController extends BaseMiniController
|
||||||
{
|
{
|
||||||
/** 小程序登录 */
|
/** 门店登录(账号 + 密码) */
|
||||||
#[PostRoute('/auth/login', authorize: false)]
|
#[PostRoute('/auth/login', authorize: false)]
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'code' => 'required|string'
|
'username' => 'required|string|max:20',
|
||||||
|
'password' => 'required|string|max:20',
|
||||||
], [
|
], [
|
||||||
'code.required' => '登录参数格式错误',
|
'username.required' => '请输入登录账号',
|
||||||
'code.string' => '登录参数格式错误',
|
'password.required' => '请输入登录密码',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$session = app(WechatService::class)->code2Session($validated['code']);
|
$store = StoreModel::where('username', $validated['username'])->first();
|
||||||
|
|
||||||
$user = UserModel::where('openid', $session['openid'])->first();
|
if ($store === null || ! password_verify($validated['password'], (string) $store->password)) {
|
||||||
|
return $this->error('账号或密码错误');
|
||||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
}
|
||||||
return $this->error('账号不存在或已被停用');
|
if ($store->status === StoreModel::STATUS_DISABLED) {
|
||||||
|
return $this->error('账号已被停用,请联系客服处理');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->last_login_at = date('Y-m-d H:i:s');
|
$store->last_login_at = date('Y-m-d H:i:s');
|
||||||
$user->save();
|
$store->save();
|
||||||
$token = $user->createToken($user->openid)->toArray();
|
|
||||||
return $this->success([
|
|
||||||
'token' => $token['plainTextToken'],
|
|
||||||
'user' => $user->toArray(),
|
|
||||||
], __('user.login_success'));
|
|
||||||
|
|
||||||
}
|
$token = $store->createToken($store->username)->toArray();
|
||||||
|
$store->load('level:id,name');
|
||||||
/** 小程序注册 */
|
|
||||||
#[PostRoute('/auth/register', authorize: false)]
|
|
||||||
public function register(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'code' => 'required|string',
|
|
||||||
'phoneCode' => 'required|string',
|
|
||||||
'storeCode' => 'required|string'
|
|
||||||
], [
|
|
||||||
'code.required' => '注册参数格式错误',
|
|
||||||
'code.string' => '注册参数格式错误',
|
|
||||||
'phoneCode.required' => '注册参数格式错误',
|
|
||||||
'phoneCode.string' => '注册参数格式错误',
|
|
||||||
'storeCode.required' => '门店编码必须填写',
|
|
||||||
'storeCode.string' => '注册参数格式错误',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$store = StoreModel::where('code', $validated['storeCode'])->first();
|
|
||||||
if (!$store) {
|
|
||||||
return $this->error('门店不存在!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过 code 换取 openid、session_key、unionid
|
|
||||||
$session = app(WechatService::class)->code2Session($validated['code']);
|
|
||||||
|
|
||||||
$user = UserModel::where('openid', $session['openid'])->first();
|
|
||||||
|
|
||||||
if ($user) {
|
|
||||||
return $this->error('你的微信已经注册,请直接登录!');
|
|
||||||
}
|
|
||||||
|
|
||||||
$userData = [
|
|
||||||
'openid' => $session['openid'],
|
|
||||||
'unionid' => $session['unionid'] ?? '',
|
|
||||||
'username' => 'wx_'.uniqid(),
|
|
||||||
'nickname' => '微信用户',
|
|
||||||
'store_id' => $store->id,
|
|
||||||
'avatar' => '',
|
|
||||||
'password' => '',
|
|
||||||
'last_login_at' => date('Y-m-d H:i:s'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$phone = app(WechatService::class)->getPhone($validated['phoneCode']);
|
|
||||||
$userData['phone'] = $phone ?? '';
|
|
||||||
|
|
||||||
$user = UserModel::create($userData);
|
|
||||||
|
|
||||||
$token = $user->createToken($user->username)->toArray();
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $token['plainTextToken'],
|
'token' => $token['plainTextToken'],
|
||||||
'user' => $user->toArray(),
|
'user' => $store->toArray(),
|
||||||
], __('user.login_success'));
|
], __('user.login_success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户信息 */
|
/** 当前门店信息(含客户等级) */
|
||||||
#[GetRoute('/auth/info')]
|
#[GetRoute('/auth/info')]
|
||||||
public function info(Request $request): JsonResponse
|
public function info(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = UserModel::with(['store.level:id,name'])
|
$store = StoreModel::with('level:id,name')->find($request->user()->id);
|
||||||
->find($request->user()->id);
|
if ($store === null) {
|
||||||
if ($user === null) {
|
|
||||||
throw new RepositoryException('账号不存在');
|
throw new RepositoryException('账号不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success($user->toArray());
|
return $this->success($store->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[PutRoute('auth/info')]
|
/** 修改登录密码 */
|
||||||
public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse
|
#[PutRoute('/auth/password')]
|
||||||
|
public function setPassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
UserModel::where('user_id', auth('user')->id())->update($request->validated());
|
$data = $request->validate([
|
||||||
|
'oldPassword' => 'required|string|max:20',
|
||||||
|
'newPassword' => 'required|string|min:6|max:20',
|
||||||
|
'rePassword' => 'required|same:newPassword',
|
||||||
|
], [
|
||||||
|
'oldPassword.required' => '请输入原密码',
|
||||||
|
'newPassword.required' => '请输入新密码',
|
||||||
|
'newPassword.min' => '新密码至少 6 位',
|
||||||
|
'rePassword.same' => '两次输入的密码不一致',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->error('更新成功');
|
$store = $this->currentStore($request);
|
||||||
|
if (! password_verify($data['oldPassword'], (string) $store->password)) {
|
||||||
|
return $this->error('原密码不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->password = password_hash($data['newPassword'], PASSWORD_DEFAULT);
|
||||||
|
$store->save();
|
||||||
|
|
||||||
|
return $this->success([], '密码修改成功');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Mini;
|
|||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
use Modules\SystemUser\Models\SysAccessToken;
|
use Modules\SystemUser\Models\SysAccessToken;
|
||||||
@@ -13,71 +12,39 @@ use Modules\SystemUser\Models\SysAccessToken;
|
|||||||
* 小程序端控制器基类
|
* 小程序端控制器基类
|
||||||
*
|
*
|
||||||
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
|
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
|
||||||
* 提供当前用户获取与门店绑定前置校验。
|
* 门店即用户:登录主体就是门店(store 表),提供当前门店获取与状态校验。
|
||||||
*/
|
*/
|
||||||
abstract class BaseMiniController extends BaseController
|
abstract class BaseMiniController extends BaseController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 当前小程序用户(auth:sanctum 注入的 tokenable)
|
* 当前登录门店(auth:sanctum 注入的 tokenable)
|
||||||
*/
|
*/
|
||||||
protected function currentUser(Request $request): UserModel
|
protected function currentStore(Request $request): StoreModel
|
||||||
{
|
{
|
||||||
$user = UserModel::find($request->user()->id);
|
$store = StoreModel::find($request->user()->id);
|
||||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
if ($store === null || $store->status === StoreModel::STATUS_DISABLED) {
|
||||||
throw new RepositoryException('账号不存在或已被停用');
|
throw new RepositoryException('账号不存在或已被停用');
|
||||||
}
|
}
|
||||||
return $user;
|
return $store;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可选登录场景手动识别当前用户(路由关闭 authorize 时使用)
|
* 可选登录场景手动识别当前门店(路由关闭 authorize 时使用)
|
||||||
*
|
*
|
||||||
* 手动解析 Bearer token;未携带 token、token 无效或非小程序用户时返回 null(不抛错)。
|
* 手动解析 Bearer token;未携带 token、token 无效或非门店账号时返回 null(不抛错)。
|
||||||
*/
|
*/
|
||||||
protected function optionalUser(Request $request): ?UserModel
|
protected function optionalStore(Request $request): ?StoreModel
|
||||||
{
|
{
|
||||||
$token = $request->bearerToken();
|
$token = $request->bearerToken();
|
||||||
if (empty($token)) {
|
if (empty($token)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$accessToken = SysAccessToken::findToken($token);
|
$accessToken = SysAccessToken::findToken($token);
|
||||||
if ($accessToken === null || $accessToken->tokenable_type !== UserModel::class) {
|
if ($accessToken === null || $accessToken->tokenable_type !== StoreModel::class) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$user = UserModel::find($accessToken->tokenable_id);
|
$store = StoreModel::find($accessToken->tokenable_id);
|
||||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
if ($store === null || $store->status === StoreModel::STATUS_DISABLED) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 门店端前置校验
|
|
||||||
*/
|
|
||||||
protected function ensureStoreBound(UserModel $user): StoreModel
|
|
||||||
{
|
|
||||||
if ($user->store_id <= 0) {
|
|
||||||
throw new RepositoryException('尚未绑定门店,请联系客服处理');
|
|
||||||
}
|
|
||||||
$store = StoreModel::find($user->store_id);
|
|
||||||
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
|
|
||||||
throw new RepositoryException('门店不存在或已停用,请联系客服处理');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错)
|
|
||||||
*/
|
|
||||||
protected function boundStore(UserModel $user): ?StoreModel
|
|
||||||
{
|
|
||||||
if ($user->store_id <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$store = StoreModel::find($user->store_id);
|
|
||||||
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ class BillController extends BaseMiniController
|
|||||||
'pageSize.max' => '每页数量最大 50',
|
'pageSize.max' => '每页数量最大 50',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$query = BillModel::query()
|
$query = BillModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -100,8 +99,7 @@ class BillController extends BaseMiniController
|
|||||||
'category_id' => 'nullable|integer|min:0',
|
'category_id' => 'nullable|integer|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $data['ids'])))));
|
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $data['ids'])))));
|
||||||
if ($ids === [] || count($ids) > 100) {
|
if ($ids === [] || count($ids) > 100) {
|
||||||
@@ -131,8 +129,7 @@ class BillController extends BaseMiniController
|
|||||||
#[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
#[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id, Request $request): JsonResponse
|
public function detail(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$bill = BillModel::with('purchase:id,purchase_no,purchase_date')
|
$bill = BillModel::with('purchase:id,purchase_no,purchase_date')
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -177,6 +174,7 @@ class BillController extends BaseMiniController
|
|||||||
'box_price' => $bill->box_price,
|
'box_price' => $bill->box_price,
|
||||||
'tray_price' => $bill->tray_price,
|
'tray_price' => $bill->tray_price,
|
||||||
'added_amount' => $bill->added_amount,
|
'added_amount' => $bill->added_amount,
|
||||||
|
'after_sale' => $bill->after_sale,
|
||||||
'total_amount' => $bill->total_amount,
|
'total_amount' => $bill->total_amount,
|
||||||
'status' => $bill->status,
|
'status' => $bill->status,
|
||||||
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
|
'status_name' => BillModel::STATUS_NAMES[$bill->status] ?? '',
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ namespace App\Http\Controllers\Mini;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Http\Requests\Mini\MiniCartRequest;
|
use App\Http\Requests\Mini\MiniCartRequest;
|
||||||
use App\Models\CartModel;
|
use App\Models\CartModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\ProductPriceModel;
|
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;
|
||||||
@@ -34,9 +35,8 @@ class CartController extends BaseMiniController
|
|||||||
#[PostRoute('/cart')]
|
#[PostRoute('/cart')]
|
||||||
public function store(MiniCartRequest $request): JsonResponse
|
public function store(MiniCartRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
if ($store->level_id <= 0 || $store->level === null) {
|
||||||
if ($store->level_id <= 0) {
|
|
||||||
return $this->error('门店未设置客户等级,无法加购,请联系客服');
|
return $this->error('门店未设置客户等级,无法加购,请联系客服');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +45,12 @@ class CartController extends BaseMiniController
|
|||||||
if ($product === null) {
|
if ($product === null) {
|
||||||
return $this->error('商品不存在或已下架,请刷新后重试');
|
return $this->error('商品不存在或已下架,请刷新后重试');
|
||||||
}
|
}
|
||||||
// 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购)
|
|
||||||
$hasPrice = ProductPriceModel::query()
|
|
||||||
->where('product_id', $productId)
|
|
||||||
->where('level_id', $store->level_id)
|
|
||||||
->exists();
|
|
||||||
if (! $hasPrice) {
|
|
||||||
return $this->error('商品「' . $product->name . '」价格未设置,无法加购');
|
|
||||||
}
|
|
||||||
|
|
||||||
$quantity = (string) $request->validated('quantity');
|
$quantity = (string) $request->validated('quantity');
|
||||||
|
|
||||||
$cart = DB::transaction(function () use ($user, $productId, $quantity) {
|
$cart = DB::transaction(function () use ($store, $productId, $quantity) {
|
||||||
$row = CartModel::query()
|
$row = CartModel::query()
|
||||||
->where('user_id', $user->id)
|
->where('store_id', $store->id)
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
@@ -75,7 +67,7 @@ class CartController extends BaseMiniController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CartModel::create([
|
return CartModel::create([
|
||||||
'user_id' => $user->id,
|
'store_id' => $store->id,
|
||||||
'product_id' => $productId,
|
'product_id' => $productId,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
]);
|
]);
|
||||||
@@ -93,11 +85,10 @@ class CartController extends BaseMiniController
|
|||||||
#[GetRoute('/cart', authorize: true)]
|
#[GetRoute('/cart', authorize: true)]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$rows = CartModel::query()
|
$rows = CartModel::query()
|
||||||
->where('user_id', $user->id)
|
->where('store_id', $store->id)
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@@ -115,11 +106,8 @@ class CartController extends BaseMiniController
|
|||||||
->unique()->values()->all();
|
->unique()->values()->all();
|
||||||
|
|
||||||
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
||||||
$priceRows = ProductPriceModel::query()
|
// 门店等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)
|
||||||
->where('level_id', $store->level_id)
|
$level = $store->level_id > 0 ? $store->level : null;
|
||||||
->whereIn('product_id', $productIds)
|
|
||||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
|
||||||
->keyBy('product_id');
|
|
||||||
|
|
||||||
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
||||||
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
||||||
@@ -141,16 +129,10 @@ class CartController extends BaseMiniController
|
|||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$product = $products->get($row->product_id);
|
$product = $products->get($row->product_id);
|
||||||
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
||||||
// 实际价(百分比计价行按成本价上浮换算);未设等级价为 null
|
// 实际价(按门店等级上浮比例换算);未设等级为 null
|
||||||
$priceRow = $priceRows->get($row->product_id);
|
$price = $level === null
|
||||||
$price = $priceRow === null
|
|
||||||
? null
|
? null
|
||||||
: ProductPriceModel::calcActualPrice(
|
: CustomerLevelModel::calcLevelPrice((float) ($product?->cost_price ?? 0), $level->percent);
|
||||||
(int) $priceRow->price_type,
|
|
||||||
$priceRow->price,
|
|
||||||
$priceRow->percent,
|
|
||||||
(float) ($product?->cost_price ?? 0),
|
|
||||||
);
|
|
||||||
$buyable = $productOn && $price !== null;
|
$buyable = $productOn && $price !== null;
|
||||||
$quantity = (string) $row->quantity;
|
$quantity = (string) $row->quantity;
|
||||||
|
|
||||||
@@ -163,6 +145,7 @@ class CartController extends BaseMiniController
|
|||||||
'name' => $product->name ?? '',
|
'name' => $product->name ?? '',
|
||||||
'spec' => $product->spec ?? '',
|
'spec' => $product->spec ?? '',
|
||||||
'unit' => $product->unit ?? '',
|
'unit' => $product->unit ?? '',
|
||||||
|
'price_unit' => $product->price_unit ?? '',
|
||||||
'image' => $firstFile?->file_url ?? '',
|
'image' => $firstFile?->file_url ?? '',
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
@@ -191,12 +174,11 @@ class CartController extends BaseMiniController
|
|||||||
#[PutRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
#[PutRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, MiniCartRequest $request): JsonResponse
|
public function update(int $id, MiniCartRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$row = CartModel::query()
|
$row = CartModel::query()
|
||||||
->where('id', $id)
|
->where('id', $id)
|
||||||
->where('user_id', $user->id)
|
->where('store_id', $store->id)
|
||||||
->first();
|
->first();
|
||||||
if ($row === null) {
|
if ($row === null) {
|
||||||
throw new RepositoryException('购物车项不存在');
|
throw new RepositoryException('购物车项不存在');
|
||||||
@@ -214,12 +196,11 @@ class CartController extends BaseMiniController
|
|||||||
#[DeleteRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
#[DeleteRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function destroy(int $id, Request $request): JsonResponse
|
public function destroy(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$deleted = CartModel::query()
|
$deleted = CartModel::query()
|
||||||
->where('id', $id)
|
->where('id', $id)
|
||||||
->where('user_id', $user->id)
|
->where('store_id', $store->id)
|
||||||
->delete();
|
->delete();
|
||||||
if ($deleted === 0) {
|
if ($deleted === 0) {
|
||||||
throw new RepositoryException('购物车项不存在');
|
throw new RepositoryException('购物车项不存在');
|
||||||
@@ -228,16 +209,24 @@ 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)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空购物车(仅当前用户)
|
* 清空购物车(仅当前用户)
|
||||||
*/
|
*/
|
||||||
#[DeleteRoute('/cart', authorize: true)]
|
#[DeleteRoute('/cart', authorize: true)]
|
||||||
public function clear(Request $request): JsonResponse
|
public function clear(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
CartModel::query()->where('user_id', $user->id)->delete();
|
CartModel::query()->where('store_id', $store->id)->delete();
|
||||||
|
|
||||||
return $this->success([], '购物车已清空');
|
return $this->success([], '购物车已清空');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,34 +11,34 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
|||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序通知(本人通知 + 全员广播)
|
* 小程序通知(本店通知 + 全员广播)
|
||||||
*
|
*
|
||||||
* 广播已读处理:user_id=0 的广播是全局共享记录,直接改 is_read 会影响其他用户,
|
* 广播已读处理:store_id=0 的广播是全局共享记录,直接改 is_read 会影响其他门店,
|
||||||
* 因此标记已读时复制一条本人专属的已读记录(data.broadcast_from 记来源),
|
* 因此标记已读时复制一条本店专属的已读记录(data.broadcast_from 记来源),
|
||||||
* 列表查询时排除已有已读副本的广播。
|
* 列表查询时排除已有已读副本的广播。
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
class NoticeController extends BaseMiniController
|
class NoticeController extends BaseMiniController
|
||||||
{
|
{
|
||||||
/** 本人通知 + 全员广播(user_id in [0, 当前id]),分页 + unread_count */
|
/** 本店通知 + 全员广播(store_id in [0, 当前id]),分页 + unread_count */
|
||||||
#[GetRoute('/notice', authorize: true)]
|
#[GetRoute('/notice', authorize: true)]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
|
|
||||||
// 本人已读过的广播来源ID(已读副本记录)
|
// 本店已读过的广播来源ID(已读副本记录)
|
||||||
$readBroadcastIds = NoticeModel::query()
|
$readBroadcastIds = NoticeModel::query()
|
||||||
->where('user_id', $user->id)
|
->where('store_id', $store->id)
|
||||||
->whereNotNull('data->broadcast_from')
|
->whereNotNull('data->broadcast_from')
|
||||||
->pluck('data')
|
->pluck('data')
|
||||||
->map(static fn (?array $data) => $data['broadcast_from'] ?? null)
|
->map(static fn (?array $data) => $data['broadcast_from'] ?? null)
|
||||||
->filter()
|
->filter()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$query = NoticeModel::query()->where(function ($q) use ($user, $readBroadcastIds) {
|
$query = NoticeModel::query()->where(function ($q) use ($store, $readBroadcastIds) {
|
||||||
$q->where('user_id', $user->id)
|
$q->where('store_id', $store->id)
|
||||||
->orWhere(function ($broadcastQuery) use ($readBroadcastIds) {
|
->orWhere(function ($broadcastQuery) use ($readBroadcastIds) {
|
||||||
$broadcastQuery->where('user_id', NoticeModel::BROADCAST_USER_ID);
|
$broadcastQuery->where('store_id', NoticeModel::BROADCAST_STORE_ID);
|
||||||
if ($readBroadcastIds->isNotEmpty()) {
|
if ($readBroadcastIds->isNotEmpty()) {
|
||||||
$broadcastQuery->whereNotIn('id', $readBroadcastIds->all());
|
$broadcastQuery->whereNotIn('id', $readBroadcastIds->all());
|
||||||
}
|
}
|
||||||
@@ -55,24 +55,24 @@ class NoticeController extends BaseMiniController
|
|||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标记已读(广播 → 复制本人已读副本;个人通知 → 直接更新) */
|
/** 标记已读(广播 → 复制本店已读副本;定向通知 → 直接更新) */
|
||||||
#[PutRoute('/notice/{id}/read', authorize: true, where: ['id' => '[0-9]+'])]
|
#[PutRoute('/notice/{id}/read', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function read(int $id, Request $request): JsonResponse
|
public function read(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
|
|
||||||
$notice = NoticeModel::whereIn('user_id', [NoticeModel::BROADCAST_USER_ID, $user->id])->find($id);
|
$notice = NoticeModel::whereIn('store_id', [NoticeModel::BROADCAST_STORE_ID, $store->id])->find($id);
|
||||||
if ($notice === null) {
|
if ($notice === null) {
|
||||||
throw new RepositoryException('通知不存在');
|
throw new RepositoryException('通知不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($notice->user_id === NoticeModel::BROADCAST_USER_ID) {
|
if ($notice->store_id === NoticeModel::BROADCAST_STORE_ID) {
|
||||||
$exists = NoticeModel::where('user_id', $user->id)
|
$exists = NoticeModel::where('store_id', $store->id)
|
||||||
->where('data->broadcast_from', $notice->id)
|
->where('data->broadcast_from', $notice->id)
|
||||||
->exists();
|
->exists();
|
||||||
if (! $exists) {
|
if (! $exists) {
|
||||||
NoticeModel::create([
|
NoticeModel::create([
|
||||||
'user_id' => $user->id,
|
'store_id' => $store->id,
|
||||||
'type' => $notice->type,
|
'type' => $notice->type,
|
||||||
'title' => $notice->title,
|
'title' => $notice->title,
|
||||||
'content' => $notice->content,
|
'content' => $notice->content,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Models\PaymentModel;
|
||||||
|
use App\Services\OnlinePaymentService;
|
||||||
|
use App\Services\WangpuPayService;
|
||||||
|
use App\Services\WechatPayService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5)
|
||||||
|
*
|
||||||
|
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
|
||||||
|
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
|
||||||
|
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
||||||
|
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
|
class OnlinePaymentController extends BaseMiniController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected OnlinePaymentService $onlinePayment,
|
||||||
|
protected WangpuPayService $wangpu,
|
||||||
|
protected WechatPayService $wxpay,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
|
||||||
|
*
|
||||||
|
* scene=mini(默认):小程序 wx.login code 换 openid,wx.requestPayment 调起;
|
||||||
|
* scene=mp:公众号 H5 网页授权 code 换 openid,WeixinJSBridge getBrandWCPayRequest 调起。
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
#[PostRoute('/payment/online', authorize: true)]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'bill_ids' => 'required|array|min:1',
|
||||||
|
'bill_ids.*' => 'integer|distinct',
|
||||||
|
'code' => 'required|string|max:64',
|
||||||
|
'scene' => 'nullable|string|in:mini,mp',
|
||||||
|
'remark' => 'nullable|string|max:255',
|
||||||
|
], [
|
||||||
|
'bill_ids.required' => '请选择要付款的账单',
|
||||||
|
'bill_ids.min' => '请选择要付款的账单',
|
||||||
|
'code.required' => '微信登录/授权凭证缺失,请重新进入',
|
||||||
|
'scene.in' => '支付场景不正确',
|
||||||
|
'remark.max' => '备注超过最大长度',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = $this->currentStore($request);
|
||||||
|
|
||||||
|
[$payment, $payParams] = $this->onlinePayment->create(
|
||||||
|
$store,
|
||||||
|
array_map('intval', $data['bill_ids']),
|
||||||
|
(string) $data['code'],
|
||||||
|
(string) ($data['remark'] ?? ''),
|
||||||
|
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 ? '支付成功' : '支付结果确认中');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旺铺支付结果后台通知(公开路由,MD5 验签后幂等结账)
|
||||||
|
*
|
||||||
|
* 通知报文为明文表单(不涉及加解密),sign 之外非空数据元 ASCII 升序拼接 + 专用加签Key 做 MD5,
|
||||||
|
* 验签通过即视为核心平台合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
|
||||||
|
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||||
|
*/
|
||||||
|
#[PostRoute('/payment/notify', authorize: false)]
|
||||||
|
public function notify(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$params = $this->wangpu->verifyNotify($request->all());
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('旺铺支付通知验签失败', ['error' => $e->getMessage()]);
|
||||||
|
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, '成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付结果后台通知(公开路由,验签 + AES-GCM 解密后幂等结账)
|
||||||
|
*
|
||||||
|
* 应答 {"code":"SUCCESS","message":"成功"} 视为通知成功,否则微信按阶梯间隔重推;
|
||||||
|
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||||
|
*/
|
||||||
|
#[PostRoute('/payment/wechat-notify', authorize: false)]
|
||||||
|
public function wechatNotify(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$params = $this->wxpay->decryptNotify($request);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('微信支付通知验签/解密失败', ['error' => $e->getMessage()]);
|
||||||
|
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, '报文验签解密失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->onlinePayment->settleByWechatNotify($params);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('微信支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
|
||||||
|
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_OK, '成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信通知应答报文(微信约定格式)
|
||||||
|
*/
|
||||||
|
protected function wechatNotifyAck(string $code, string $msg): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'code' => $code,
|
||||||
|
'message' => mb_substr($msg, 0, 64),
|
||||||
|
], $code === WechatPayService::NOTIFY_ACK_OK ? 200 : 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS)
|
||||||
|
*/
|
||||||
|
protected function notifyAck(string $code, string $msg): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'code' => $code,
|
||||||
|
'msg' => mb_substr($msg, 0, 100),
|
||||||
|
'timestamp' => now()->format('YmdHisv'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ 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\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\ProductPriceModel;
|
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Services\BillNumberService;
|
use App\Services\BillNumberService;
|
||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
|
use App\Services\WeightEstimator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -30,16 +33,40 @@ class OrderController extends BaseMiniController
|
|||||||
#[PostRoute('/order', authorize: true)]
|
#[PostRoute('/order', authorize: true)]
|
||||||
public function store(MiniOrderRequest $request): JsonResponse
|
public function store(MiniOrderRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
if ($store->level_id <= 0) {
|
// 截单时间校验:业务配置 services.order_time_start / order_time_end,均未配置时不限制
|
||||||
|
$this->assertWithinOrderTimeWindow();
|
||||||
|
|
||||||
|
$level = $store->level_id > 0 ? $store->level : null;
|
||||||
|
if ($level === null) {
|
||||||
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') ?? '');
|
||||||
|
|
||||||
$order = DB::transaction(function () use ($store, $items, $remark) {
|
$order = DB::transaction(function () use ($store, $level, $items, $remark) {
|
||||||
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
|
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
|
||||||
|
|
||||||
$products = ProductModel::query()
|
$products = ProductModel::query()
|
||||||
@@ -48,14 +75,9 @@ class OrderController extends BaseMiniController
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$priceRows = ProductPriceModel::query()
|
|
||||||
->where('level_id', $store->level_id)
|
|
||||||
->whereIn('product_id', $productIds)
|
|
||||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
|
||||||
->keyBy('product_id');
|
|
||||||
|
|
||||||
$totalQuantity = '0';
|
$totalQuantity = '0';
|
||||||
$totalAmount = '0';
|
$totalAmount = '0';
|
||||||
|
$totalWeight = '0';
|
||||||
$now = now();
|
$now = now();
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($items as $row) {
|
foreach ($items as $row) {
|
||||||
@@ -64,22 +86,16 @@ class OrderController extends BaseMiniController
|
|||||||
if ($product === null) {
|
if ($product === null) {
|
||||||
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
||||||
}
|
}
|
||||||
if (! isset($priceRows[$productId])) {
|
|
||||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price)
|
// 实际价(按门店等级上浮比例换算,$products 已含 cost_price)
|
||||||
$priceRow = $priceRows[$productId];
|
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||||
$price = ProductPriceModel::calcActualPrice(
|
|
||||||
(int) $priceRow->price_type,
|
|
||||||
$priceRow->price,
|
|
||||||
$priceRow->percent,
|
|
||||||
$product->cost_price,
|
|
||||||
);
|
|
||||||
$quantity = (string) $row['quantity'];
|
$quantity = (string) $row['quantity'];
|
||||||
$amount = bcmul($price, $quantity, 2);
|
$amount = bcmul($price, $quantity, 2);
|
||||||
|
// 参考重量 = 订货量 × 规格折算(仅作参考,实际称重以采购录入为准)
|
||||||
|
$weight = WeightEstimator::estimate((string) $product->spec, (string) $product->unit, $quantity);
|
||||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||||
$totalAmount = bcadd($totalAmount, $amount, 2);
|
$totalAmount = bcadd($totalAmount, $amount, 2);
|
||||||
|
$totalWeight = bcadd($totalWeight, $weight, 3);
|
||||||
|
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
@@ -90,11 +106,12 @@ class OrderController extends BaseMiniController
|
|||||||
'product_spec' => $product->spec,
|
'product_spec' => $product->spec,
|
||||||
'unit' => (string) $product->unit,
|
'unit' => (string) $product->unit,
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
|
'price_unit' => $product->price_unit,
|
||||||
'image_ids' => implode(',', (array) $product->image_ids),
|
'image_ids' => implode(',', (array) $product->image_ids),
|
||||||
'content' => (string) $product->content,
|
'content' => (string) $product->content,
|
||||||
'shelf_life' => (int) $product->shelf_life,
|
'shelf_life' => (int) $product->shelf_life,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => 0,
|
'weight' => $weight,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'cost_price' => (string) $product->cost_price,
|
'cost_price' => (string) $product->cost_price,
|
||||||
'remark' => '',
|
'remark' => '',
|
||||||
@@ -108,7 +125,7 @@ class OrderController extends BaseMiniController
|
|||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
'order_date' => $now->toDateString(),
|
'order_date' => $now->toDateString(),
|
||||||
'total_quantity' => $totalQuantity,
|
'total_quantity' => $totalQuantity,
|
||||||
'total_weight' => 0,
|
'total_weight' => $totalWeight,
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'status' => StoreOrderModel::STATUS_PENDING,
|
'status' => StoreOrderModel::STATUS_PENDING,
|
||||||
'remark' => $remark,
|
'remark' => $remark,
|
||||||
@@ -119,6 +136,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;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,8 +175,7 @@ class OrderController extends BaseMiniController
|
|||||||
'pageSize.max' => '每页数量最大 50',
|
'pageSize.max' => '每页数量最大 50',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$query = StoreOrderModel::query()
|
$query = StoreOrderModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -234,8 +256,7 @@ class OrderController extends BaseMiniController
|
|||||||
throw new RepositoryException('period 参数只能是 day/week/month');
|
throw new RepositoryException('period 参数只能是 day/week/month');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite)
|
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite)
|
||||||
$driver = DB::connection()->getDriverName();
|
$driver = DB::connection()->getDriverName();
|
||||||
@@ -273,8 +294,7 @@ class OrderController extends BaseMiniController
|
|||||||
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id, Request $request): JsonResponse
|
public function detail(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$order = StoreOrderModel::with('items')
|
$order = StoreOrderModel::with('items')
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -290,8 +310,7 @@ class OrderController extends BaseMiniController
|
|||||||
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
|
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function cancel(int $id, Request $request): JsonResponse
|
public function cancel(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
|
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
|
||||||
if ($order === null) {
|
if ($order === null) {
|
||||||
@@ -306,4 +325,44 @@ class OrderController extends BaseMiniController
|
|||||||
|
|
||||||
return $this->success([], '订单已取消');
|
return $this->success([], '订单已取消');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截单时间校验:业务配置 services.order_time_start / order_time_end(HH:mm)
|
||||||
|
* 均留空不限制;只配一端按单边限制;开始时间晚于截单时间表示跨天时段(如 20:00-次日06:00);
|
||||||
|
* 格式非法的配置按未配置处理,避免误配置导致全天无法下单
|
||||||
|
*/
|
||||||
|
private function assertWithinOrderTimeWindow(): void
|
||||||
|
{
|
||||||
|
$parse = static function (mixed $value): ?int {
|
||||||
|
$value = trim((string) $value);
|
||||||
|
if (! preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $value, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (int) $matches[1] * 60 + (int) $matches[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
$startText = trim((string) site_config('services.order_time_start', ''));
|
||||||
|
$endText = trim((string) site_config('services.order_time_end', ''));
|
||||||
|
$start = $parse($startText);
|
||||||
|
$end = $parse($endText);
|
||||||
|
if ($start === null && $end === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($start !== null && $start === $end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = (int) now()->format('H') * 60 + (int) now()->format('i');
|
||||||
|
$allowed = match (true) {
|
||||||
|
// 跨天时段:当晚开始时间之后 或 次日截单时间之前
|
||||||
|
$start !== null && $end !== null && $start > $end => $now >= $start || $now <= $end,
|
||||||
|
$start !== null && $end !== null => $now >= $start && $now <= $end,
|
||||||
|
$start !== null => $now >= $start,
|
||||||
|
default => $now <= $end,
|
||||||
|
};
|
||||||
|
if (! $allowed) {
|
||||||
|
$window = ($startText !== '' ? $startText : '00:00') . ' - ' . ($endText !== '' ? $endText : '24:00');
|
||||||
|
throw new RepositoryException('当前不在下单时段内(下单时间 ' . $window . '),请在规定时间内下单');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
|
|||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Services\BillNumberService;
|
use App\Services\BillNumberService;
|
||||||
|
use App\Services\WechatMpService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -37,10 +38,19 @@ class PaymentController extends BaseMiniController
|
|||||||
return $value;
|
return $value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 支付方式开关(Checkbox 配置自动转数组;未配置时默认全部启用)
|
||||||
|
$payMethods = site_config('pay.pay_methods');
|
||||||
|
if (! is_array($payMethods)) {
|
||||||
|
$payMethods = ['wechat_qrcode', 'alipay_qrcode', 'bank', 'online'];
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
|
'pay_methods' => array_values($payMethods),
|
||||||
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
||||||
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
||||||
'bank_info' => (string) site_config('pay.bank_info', ''),
|
'bank_info' => (string) site_config('pay.bank_info', ''),
|
||||||
|
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp)
|
||||||
|
'mp_appid' => app(WechatMpService::class)->appid(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +58,7 @@ class PaymentController extends BaseMiniController
|
|||||||
#[GetRoute('/payment', authorize: true)]
|
#[GetRoute('/payment', authorize: true)]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$query = PaymentModel::query()
|
$query = PaymentModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -89,11 +98,10 @@ class PaymentController extends BaseMiniController
|
|||||||
'remark.max' => '备注超过最大长度',
|
'remark.max' => '备注超过最大长度',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
$billIds = array_map('intval', $data['bill_ids']);
|
$billIds = array_map('intval', $data['bill_ids']);
|
||||||
|
|
||||||
$payment = DB::transaction(function () use ($store, $user, $data, $billIds) {
|
$payment = DB::transaction(function () use ($store, $data, $billIds) {
|
||||||
$bills = BillModel::query()
|
$bills = BillModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
->whereIn('id', $billIds)
|
->whereIn('id', $billIds)
|
||||||
@@ -119,7 +127,6 @@ class PaymentController extends BaseMiniController
|
|||||||
$payment = PaymentModel::create([
|
$payment = PaymentModel::create([
|
||||||
'payment_no' => app(BillNumberService::class)->make('ZF'),
|
'payment_no' => app(BillNumberService::class)->make('ZF'),
|
||||||
'store_id' => $store->id,
|
'store_id' => $store->id,
|
||||||
'user_id' => $user->id,
|
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'pay_method' => (int) $data['pay_method'],
|
'pay_method' => (int) $data['pay_method'],
|
||||||
'voucher_ids' => array_map('intval', $data['voucher_ids']),
|
'voucher_ids' => array_map('intval', $data['voucher_ids']),
|
||||||
@@ -144,8 +151,7 @@ class PaymentController extends BaseMiniController
|
|||||||
#[GetRoute('/payment/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
#[GetRoute('/payment/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id, Request $request): JsonResponse
|
public function detail(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$payment = PaymentModel::query()
|
$payment = PaymentModel::query()
|
||||||
->where('store_id', $store->id)
|
->where('store_id', $store->id)
|
||||||
@@ -156,7 +162,7 @@ class PaymentController extends BaseMiniController
|
|||||||
|
|
||||||
$bills = $payment->bills()
|
$bills = $payment->bills()
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
|
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$data = $payment->toArray();
|
$data = $payment->toArray();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Mini;
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\ProductCategoryModel;
|
use App\Models\ProductCategoryModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\ProductPriceModel;
|
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;
|
||||||
@@ -18,14 +19,15 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|||||||
class ProductController extends BaseMiniController
|
class ProductController extends BaseMiniController
|
||||||
{
|
{
|
||||||
/** 分类树(全部启用分类,含暂无商品的分类) */
|
/** 分类树(全部启用分类,含暂无商品的分类) */
|
||||||
#[GetRoute('/product/categories', authorize: true)]
|
#[GetRoute('/product/categories', authorize: false)]
|
||||||
public function categories(): JsonResponse
|
public function categories(): JsonResponse
|
||||||
{
|
{
|
||||||
return $this->success(ProductCategoryModel::getTreeData(['*'], true));
|
return $this->success(ProductCategoryModel::getTreeData(['*'], true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品列表
|
* 商品列表(登录门店附 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
|
||||||
@@ -45,45 +47,51 @@ class ProductController extends BaseMiniController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先按分类树展示顺序(与小程序分类栏一致;不在启用分类树中的商品排最后),再按商品排序
|
||||||
|
$categoryIds = ProductCategoryModel::getTreeOrderedIds(true);
|
||||||
|
if ($categoryIds !== []) {
|
||||||
|
$cases = [];
|
||||||
|
foreach ($categoryIds as $position => $categoryId) {
|
||||||
|
$cases[] = "WHEN {$categoryId} THEN {$position}";
|
||||||
|
}
|
||||||
|
$query->orderByRaw(
|
||||||
|
'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$pageSize = (int) $request->input('pageSize', 10);
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
$paginator = $query->orderBy('sort')
|
$paginator = $query->orderBy('sort')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->paginate($pageSize);
|
->paginate($pageSize);
|
||||||
|
|
||||||
// 当前门店的等级价格(一次性取出,避免逐行查询)
|
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)与购物车行
|
||||||
$user = $this->optionalUser($request);
|
$store = $this->optionalStore($request);
|
||||||
$store = $user !== null ? $this->boundStore($user) : null;
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
$priceRows = collect();
|
$cartService = app(CartService::class);
|
||||||
if ($store !== null && $store->level_id > 0) {
|
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
|
||||||
$priceRows = ProductPriceModel::query()
|
|
||||||
->where('level_id', $store->level_id)
|
|
||||||
->whereIn('product_id', $paginator->getCollection()->pluck('id'))
|
|
||||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
|
||||||
->keyBy('product_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
$paginator->getCollection()->transform(
|
$paginator->getCollection()->transform(
|
||||||
static function (ProductModel $product) use ($priceRows): array {
|
static function (ProductModel $product) use ($level, $cartRows): array {
|
||||||
$row = $product->toArray();
|
$row = $product->toArray();
|
||||||
// 实际价(百分比计价行按成本价上浮换算;成本价不随序列化输出)
|
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
|
||||||
$priceRow = $priceRows->get($product->id);
|
$row['price'] = $level !== null
|
||||||
$row['price'] = $priceRow !== null
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
? ProductPriceModel::calcActualPrice(
|
|
||||||
(int) $priceRow->price_type,
|
|
||||||
(string) $priceRow->price,
|
|
||||||
(string) $priceRow->percent,
|
|
||||||
(string) $product->cost_price,
|
|
||||||
)
|
|
||||||
: 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品详情(免登录浏览;登录门店按等级显示换算价,未登录/未绑店/未设等级 price=null)
|
* 商品详情(免登录浏览;登录门店按等级上浮比例显示换算价,未登录/未设等级 price=null)
|
||||||
*/
|
*/
|
||||||
#[GetRoute('/product/{id}', authorize: false, where: ['id' => '[0-9]+'])]
|
#[GetRoute('/product/{id}', authorize: false, where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id, Request $request): JsonResponse
|
public function detail(int $id, Request $request): JsonResponse
|
||||||
@@ -97,24 +105,18 @@ class ProductController extends BaseMiniController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$price = null;
|
$price = null;
|
||||||
$user = $this->optionalUser($request);
|
$store = $this->optionalStore($request);
|
||||||
$store = $user !== null ? $this->boundStore($user) : null;
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
if ($store !== null && $store->level_id > 0) {
|
if ($level !== null) {
|
||||||
$priceRow = ProductPriceModel::query()
|
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||||
->forProductLevel($product->id, $store->level_id)
|
|
||||||
->first(['price', 'price_type', 'percent']);
|
|
||||||
if ($priceRow !== null) {
|
|
||||||
$price = ProductPriceModel::calcActualPrice(
|
|
||||||
(int) $priceRow->price_type,
|
|
||||||
(string) $priceRow->price,
|
|
||||||
(string) $priceRow->percent,
|
|
||||||
(string) $product->cost_price,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Models\StoreOrderItemModel;
|
||||||
|
use App\Models\StoreOrderModel;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序运营报表(门店视角:采购金额汇总与单品占比)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
|
class ReportController extends BaseMiniController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 采购金额报表:选定周期内采购总金额 + 每个单品累计采购金额/占比
|
||||||
|
*
|
||||||
|
* ?preset=week|last_week|month|last_month 周期预设(默认 month 本月);
|
||||||
|
* ?start_date=&end_date= 自定义区间(Y-m-d,需成对出现,优先于 preset)。
|
||||||
|
* 统计口径:本店订货单(排除已取消与后台已删除订单),金额=订货明细快照金额之和,
|
||||||
|
* 单品按 product_id 聚合(品名/规格/单位取明细快照),按金额降序,占比保留 1 位小数(四舍五入)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/report/purchase', authorize: true)]
|
||||||
|
public function purchase(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$params = $request->validate([
|
||||||
|
'preset' => 'nullable|string|in:week,last_week,month,last_month',
|
||||||
|
'start_date' => 'required_with:end_date|nullable|date_format:Y-m-d',
|
||||||
|
'end_date' => 'required_with:start_date|nullable|date_format:Y-m-d|after_or_equal:start_date',
|
||||||
|
], [
|
||||||
|
'preset.in' => 'preset 参数只能是 week/last_week/month/last_month',
|
||||||
|
'start_date.required_with' => '自定义区间需同时提供开始与结束日期',
|
||||||
|
'end_date.required_with' => '自定义区间需同时提供开始与结束日期',
|
||||||
|
'start_date.date_format' => '开始日期格式为 Y-m-d',
|
||||||
|
'end_date.date_format' => '结束日期格式为 Y-m-d',
|
||||||
|
'end_date.after_or_equal' => '结束日期不能早于开始日期',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = $this->currentStore($request);
|
||||||
|
|
||||||
|
[$startDate, $endDate, $preset] = $this->resolveDateRange($params);
|
||||||
|
|
||||||
|
// 明细联订货单:按订单日期/状态过滤(已取消、已删除订单不计入)
|
||||||
|
$baseQuery = StoreOrderItemModel::query()
|
||||||
|
->join('store_order', static function ($join): void {
|
||||||
|
$join->on('store_order.id', '=', 'store_order_item.order_id')
|
||||||
|
->whereNull('store_order.deleted_at');
|
||||||
|
})
|
||||||
|
->where('store_order_item.store_id', $store->id)
|
||||||
|
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
|
||||||
|
->whereDate('store_order.order_date', '>=', $startDate)
|
||||||
|
->whereDate('store_order.order_date', '<=', $endDate);
|
||||||
|
|
||||||
|
$totals = (clone $baseQuery)
|
||||||
|
->selectRaw('COALESCE(SUM(store_order_item.amount), 0) as total_amount')
|
||||||
|
->selectRaw('COALESCE(SUM(store_order_item.quantity), 0) as total_quantity')
|
||||||
|
->selectRaw('COUNT(DISTINCT store_order_item.order_id) as order_count')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$totalAmount = bcadd((string) $totals->total_amount, '0', 2);
|
||||||
|
|
||||||
|
$rows = (clone $baseQuery)
|
||||||
|
->select('store_order_item.product_id')
|
||||||
|
->selectRaw('MAX(store_order_item.product_name) as product_name')
|
||||||
|
->selectRaw('MAX(store_order_item.product_spec) as product_spec')
|
||||||
|
->selectRaw('MAX(store_order_item.unit) as unit')
|
||||||
|
->selectRaw('COALESCE(SUM(store_order_item.quantity), 0) as quantity')
|
||||||
|
->selectRaw('COALESCE(SUM(store_order_item.weight), 0) as weight')
|
||||||
|
->selectRaw('COALESCE(SUM(store_order_item.amount), 0) as amount')
|
||||||
|
->groupBy('store_order_item.product_id')
|
||||||
|
->orderByDesc('amount')
|
||||||
|
->orderBy('store_order_item.product_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$items = $rows->map(static function (StoreOrderItemModel $row) use ($totalAmount): array {
|
||||||
|
// 占比 %(1 位小数,四舍五入):tenths = round(amount × 1000 ÷ total),再 ÷10
|
||||||
|
$percent = 0.0;
|
||||||
|
if (bccomp($totalAmount, '0', 2) > 0) {
|
||||||
|
$tenths = bcdiv(
|
||||||
|
bcadd(bcmul((string) $row->amount, '1000', 2), bcdiv($totalAmount, '2', 0), 2),
|
||||||
|
$totalAmount,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
$percent = (float) bcdiv($tenths, '10', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product_id' => (int) $row->product_id,
|
||||||
|
'product_name' => (string) $row->product_name,
|
||||||
|
'product_spec' => (string) $row->product_spec,
|
||||||
|
'unit' => (string) $row->unit,
|
||||||
|
'quantity' => (int) $row->quantity,
|
||||||
|
'weight' => bcadd((string) $row->weight, '0', 3),
|
||||||
|
'amount' => bcadd((string) $row->amount, '0', 2),
|
||||||
|
'percent' => $percent,
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'preset' => $preset,
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'total_quantity' => (int) $totals->total_quantity,
|
||||||
|
'order_count' => (int) $totals->order_count,
|
||||||
|
'item_count' => count($items),
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析统计区间:自定义日期优先;否则按 preset 推导(进行中的周期封顶到今天)
|
||||||
|
*
|
||||||
|
* @param array{preset?: ?string, start_date?: ?string, end_date?: ?string} $params
|
||||||
|
* @return array{0: string, 1: string, 2: string} [开始日期, 结束日期, 实际 preset]
|
||||||
|
*/
|
||||||
|
private function resolveDateRange(array $params): array
|
||||||
|
{
|
||||||
|
if (! empty($params['start_date']) && ! empty($params['end_date'])) {
|
||||||
|
return [$params['start_date'], $params['end_date'], 'custom'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$preset = (string) ($params['preset'] ?? 'month');
|
||||||
|
$today = Carbon::today();
|
||||||
|
[$start, $end] = match ($preset) {
|
||||||
|
'week' => [$today->copy()->startOfWeek(), $today->copy()->endOfWeek()],
|
||||||
|
'last_week' => [$today->copy()->subWeek()->startOfWeek(), $today->copy()->subWeek()->endOfWeek()],
|
||||||
|
'last_month' => [$today->copy()->subMonthNoOverflow()->startOfMonth(), $today->copy()->subMonthNoOverflow()->endOfMonth()],
|
||||||
|
default => [$today->copy()->startOfMonth(), $today->copy()->endOfMonth()],
|
||||||
|
};
|
||||||
|
// 未来日期本无数据,封顶到今天避免误解
|
||||||
|
if ($end->greaterThan($today)) {
|
||||||
|
$end = $today->copy();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$start->toDateString(), $end->toDateString(), $preset];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Services\CartService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序特价推荐
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
|
class SpecialController extends BaseMiniController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 特价推荐商品列表(免登录浏览;行结构与 /mini/product/list 一致:
|
||||||
|
* 登录门店附等级价 price 与 cart_id/cart_quantity 供列表直接加减购物车;
|
||||||
|
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/special/list', authorize: false)]
|
||||||
|
public function specials(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
|
$paginator = HomeSpecialModel::query()
|
||||||
|
->where('status', HomeSpecialModel::STATUS_NORMAL)
|
||||||
|
->whereHas('product', static function ($q) {
|
||||||
|
$q->where('status', ProductModel::STATUS_ON);
|
||||||
|
})
|
||||||
|
->with('product')
|
||||||
|
->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize);
|
||||||
|
|
||||||
|
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)与购物车行
|
||||||
|
$store = $this->optionalStore($request);
|
||||||
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
|
$cartService = app(CartService::class);
|
||||||
|
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
|
||||||
|
|
||||||
|
$paginator->getCollection()->transform(
|
||||||
|
static function (HomeSpecialModel $special) use ($level, $cartRows): array {
|
||||||
|
$product = $special->product;
|
||||||
|
$row = $product->toArray();
|
||||||
|
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
|
||||||
|
$row['price'] = $level !== null
|
||||||
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
|
: null;
|
||||||
|
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00')
|
||||||
|
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
|
||||||
|
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $paginator->toArray();
|
||||||
|
$data['cart'] = $cartService->summary($store);
|
||||||
|
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,7 @@ class StoreController extends BaseMiniController
|
|||||||
#[GetRoute('/store/info', authorize: true)]
|
#[GetRoute('/store/info', authorize: true)]
|
||||||
public function info(Request $request): JsonResponse
|
public function info(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'id' => $store->id,
|
'id' => $store->id,
|
||||||
@@ -46,8 +45,7 @@ class StoreController extends BaseMiniController
|
|||||||
'address.max' => '地址最长 255 个字符',
|
'address.max' => '地址最长 255 个字符',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
$store = $this->ensureStoreBound($user);
|
|
||||||
|
|
||||||
$store->update($data);
|
$store->update($data);
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ class UploadController extends BaseMiniController
|
|||||||
'file.max' => '图片不能超过 5MB',
|
'file.max' => '图片不能超过 5MB',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->currentUser($request);
|
$store = $this->currentStore($request);
|
||||||
// 分组 4=用户上传,渠道 20=APP用户
|
// 分组 4=用户上传,渠道 20=APP用户
|
||||||
$result = app(SysFileService::class)->upload($data['file'], 4, 20, $user->id);
|
$result = app(SysFileService::class)->upload($data['file'], 4, 20, $store->id);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'id' => $result['id'],
|
'id' => $result['id'],
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace App\Http\Controllers\Order;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\NoticeModel;
|
use App\Models\NoticeModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
use App\Models\UserModel;
|
|
||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -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')
|
||||||
@@ -219,19 +228,21 @@ class StoreOrderController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态流转后通知门店用户
|
* 状态流转后通知门店
|
||||||
*/
|
*/
|
||||||
private function notifyStore(StoreOrderModel $order): void
|
private function notifyStore(StoreOrderModel $order): void
|
||||||
{
|
{
|
||||||
$userIds = UserModel::query()
|
$store = StoreModel::query()
|
||||||
->where('store_id', $order->store_id)
|
->where('id', $order->store_id)
|
||||||
->where('status', UserModel::STATUS_NORMAL)
|
->where('status', StoreModel::STATUS_NORMAL)
|
||||||
->pluck('id');
|
->first();
|
||||||
|
if ($store === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$statusName = StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status;
|
$statusName = StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status;
|
||||||
foreach ($userIds as $userId) {
|
|
||||||
NoticeModel::create([
|
NoticeModel::create([
|
||||||
'user_id' => $userId,
|
'store_id' => $store->id,
|
||||||
'type' => NoticeModel::TYPE_ORDER,
|
'type' => NoticeModel::TYPE_ORDER,
|
||||||
'title' => '订单状态更新',
|
'title' => '订单状态更新',
|
||||||
'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}」", 0, 500),
|
'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}」", 0, 500),
|
||||||
@@ -239,7 +250,6 @@ class StoreOrderController extends BaseController
|
|||||||
'is_read' => NoticeModel::UNREAD,
|
'is_read' => NoticeModel::UNREAD,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单输出数组的关联账单补充支付进度(待支付/审核中/已支付,与小程序端口径一致)
|
* 订单输出数组的关联账单补充支付进度(待支付/审核中/已支付,与小程序端口径一致)
|
||||||
|
|||||||
@@ -3,18 +3,19 @@
|
|||||||
namespace App\Http\Controllers\Product;
|
namespace App\Http\Controllers\Product;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Exports\ProductExport;
|
||||||
use App\Http\Requests\Product\BatchPriceRequest;
|
use App\Http\Requests\Product\BatchPriceRequest;
|
||||||
use App\Http\Requests\Product\ProductFormRequest;
|
use App\Http\Requests\Product\ProductFormRequest;
|
||||||
|
use App\Imports\ProductImport;
|
||||||
use App\Models\CustomerLevelModel;
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\NoticeModel;
|
use App\Models\NoticeModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
use App\Models\ProductPriceModel;
|
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
@@ -22,9 +23,10 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
|||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
use Modules\SystemTool\Services\SysFileService;
|
use Modules\SystemTool\Services\SysFileService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品档案管理
|
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/product/goods', 'product.goods')]
|
#[RequestAttribute('/product/goods', 'product.goods')]
|
||||||
class ProductController extends BaseController
|
class ProductController extends BaseController
|
||||||
@@ -33,12 +35,13 @@ class ProductController extends BaseController
|
|||||||
'name' => 'like',
|
'name' => 'like',
|
||||||
'category_id' => '=',
|
'category_id' => '=',
|
||||||
'supplier_id' => '=',
|
'supplier_id' => '=',
|
||||||
|
'market' => 'like',
|
||||||
'status' => '=',
|
'status' => '=',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $quickSearchField = ['name', 'spec'];
|
protected array $quickSearchField = ['name', 'spec'];
|
||||||
|
|
||||||
/** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
/** A1 商品列表(含分类/供应商;prices 为按启用等级上浮比例换算的展示价;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||||
#[GetRoute(authorize: 'query')]
|
#[GetRoute(authorize: 'query')]
|
||||||
public function query(Request $request): JsonResponse
|
public function query(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -46,12 +49,26 @@ class ProductController extends BaseController
|
|||||||
$pageSize = $params['pageSize'] ?? 10;
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
$data = $this->buildSearch(
|
$data = $this->buildSearch(
|
||||||
$params,
|
$params,
|
||||||
ProductModel::query()->with(['category:id,name', 'supplier:id,name', 'prices.level:id,name'])
|
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
||||||
)
|
)
|
||||||
->orderBy('sort')
|
->orderBy('sort')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize);
|
->paginate($pageSize);
|
||||||
$data->getCollection()->makeVisible('cost_price');
|
$data->getCollection()->makeVisible('cost_price');
|
||||||
|
|
||||||
|
// 按启用等级直接换算展示价(无等级价格表,价格由成本价 × 等级上浮比例得出)
|
||||||
|
$levels = $this->enabledLevels();
|
||||||
|
$data->getCollection()->transform(static function (ProductModel $product) use ($levels) {
|
||||||
|
$row = $product->toArray();
|
||||||
|
$row['prices'] = $levels->map(static fn (CustomerLevelModel $level) => [
|
||||||
|
'level_id' => $level->id,
|
||||||
|
'level' => ['id' => $level->id, 'name' => $level->name],
|
||||||
|
'percent' => (float) $level->percent,
|
||||||
|
'price' => CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent),
|
||||||
|
])->values()->all();
|
||||||
|
return $row;
|
||||||
|
});
|
||||||
|
|
||||||
return $this->success($data->toArray());
|
return $this->success($data->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,32 +87,62 @@ class ProductController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 创建商品(事务内建商品 + 同步等级价格) */
|
/** 创建商品 */
|
||||||
#[PostRoute(authorize: 'create')]
|
#[PostRoute(authorize: 'create')]
|
||||||
public function create(ProductFormRequest $request): JsonResponse
|
public function create(ProductFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$product = ProductModel::create($request->validated());
|
||||||
$prices = $validated['prices'] ?? [];
|
|
||||||
unset($validated['prices']);
|
|
||||||
|
|
||||||
$product = DB::transaction(function () use ($validated, $prices) {
|
|
||||||
$product = ProductModel::create($validated);
|
|
||||||
foreach ($prices as $row) {
|
|
||||||
ProductPriceModel::create([
|
|
||||||
'product_id' => $product->id,
|
|
||||||
'level_id' => (int) $row['level_id'],
|
|
||||||
'price' => $row['price'],
|
|
||||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
|
||||||
'percent' => $row['percent'] ?? 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return $product;
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this->success(['id' => $product->id]);
|
return $this->success(['id' => $product->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑商品(prices 按 level_id upsert,删除已移除的等级行;未提交 prices 键时保持原价) */
|
/**
|
||||||
|
* 商品导出(列格式与导入模板一致,导出文件修改后可直接重新导入);
|
||||||
|
* template=1 时仅输出列头 + 示例行(导入模板)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/export', 'export')]
|
||||||
|
public function export(Request $request): Response
|
||||||
|
{
|
||||||
|
$template = $request->boolean('template');
|
||||||
|
$categoryId = max(0, (int) $request->query('category_id', 0));
|
||||||
|
$filename = $template
|
||||||
|
? '商品导入模板.xlsx'
|
||||||
|
: '商品列表_' . now()->format('Ymd_His') . '.xlsx';
|
||||||
|
return Excel::download(new ProductExport($categoryId, $template), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel 批量导入商品:整表校验,有错全部不导入;
|
||||||
|
* 分类按名称/路径匹配末级分类,供应商按名称匹配、不存在自动创建;一律新增商品
|
||||||
|
*/
|
||||||
|
#[PostRoute('/import', 'import')]
|
||||||
|
public function import(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||||
|
], [
|
||||||
|
'file.required' => '请选择要导入的 Excel 文件',
|
||||||
|
'file.mimes' => '仅支持 xlsx/xls 格式的 Excel 文件',
|
||||||
|
'file.max' => '文件大小不能超过 10MB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = new ProductImport();
|
||||||
|
Excel::import($import, $data['file']);
|
||||||
|
|
||||||
|
if ($import->errors !== []) {
|
||||||
|
return $this->error(
|
||||||
|
['errors' => $import->errors],
|
||||||
|
'共发现 ' . count($import->errors) . ' 处数据错误,请修正后重新导入'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = '成功导入 ' . $import->created . ' 件商品';
|
||||||
|
if ($import->suppliersCreated !== []) {
|
||||||
|
$msg .= ',自动创建供应商:' . implode('、', $import->suppliersCreated);
|
||||||
|
}
|
||||||
|
return $this->success(['created' => $import->created], $msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑商品 */
|
||||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -103,34 +150,11 @@ class ProductController extends BaseController
|
|||||||
if (empty($product)) {
|
if (empty($product)) {
|
||||||
throw new RepositoryException('商品不存在');
|
throw new RepositoryException('商品不存在');
|
||||||
}
|
}
|
||||||
$validated = $request->validated();
|
$product->update($request->validated());
|
||||||
$prices = $validated['prices'] ?? [];
|
|
||||||
unset($validated['prices']);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($product, $validated, $prices, $request) {
|
|
||||||
$product->update($validated);
|
|
||||||
if ($request->has('prices')) {
|
|
||||||
$levelIds = [];
|
|
||||||
foreach ($prices as $row) {
|
|
||||||
$levelId = (int) $row['level_id'];
|
|
||||||
$levelIds[] = $levelId;
|
|
||||||
ProductPriceModel::updateOrCreate(
|
|
||||||
['product_id' => $product->id, 'level_id' => $levelId],
|
|
||||||
[
|
|
||||||
'price' => $row['price'],
|
|
||||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
|
||||||
'percent' => $row['percent'] ?? 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$product->prices()->whereNotIn('level_id', $levelIds)->delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除商品(软删除,连带价格行一并删除) */
|
/** 删除商品(软删除) */
|
||||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||||
public function delete(int $id): JsonResponse
|
public function delete(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -138,22 +162,35 @@ class ProductController extends BaseController
|
|||||||
if (empty($product)) {
|
if (empty($product)) {
|
||||||
throw new RepositoryException('商品不存在');
|
throw new RepositoryException('商品不存在');
|
||||||
}
|
}
|
||||||
DB::transaction(function () use ($product) {
|
|
||||||
$product->prices()->delete();
|
|
||||||
$product->delete();
|
$product->delete();
|
||||||
});
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除商品(软删除),复用 delete 权限点 */
|
||||||
|
#[DeleteRoute('/batch', 'delete')]
|
||||||
|
public function batchDelete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'ids' => 'required|array|min:1',
|
||||||
|
'ids.*' => 'integer|distinct',
|
||||||
|
], [
|
||||||
|
'ids.required' => '请选择要删除的商品',
|
||||||
|
'ids.min' => '请选择要删除的商品',
|
||||||
|
'ids.*.integer' => '商品 ID 格式错误',
|
||||||
|
'ids.*.distinct' => '存在重复的商品',
|
||||||
|
]);
|
||||||
|
ProductModel::whereIn('id', $data['ids'])->delete();
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||||
* 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级
|
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
|
||||||
* price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算
|
|
||||||
*/
|
*/
|
||||||
#[GetRoute('/priceMatrix', 'query')]
|
#[GetRoute('/priceMatrix', 'query')]
|
||||||
public function priceMatrix(Request $request): JsonResponse
|
public function priceMatrix(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent');
|
$query = ProductModel::query();
|
||||||
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
||||||
$query->where('category_id', $categoryId);
|
$query->where('category_id', $categoryId);
|
||||||
}
|
}
|
||||||
@@ -168,13 +205,9 @@ class ProductController extends BaseController
|
|||||||
$page = max(1, (int) $request->input('page', 1));
|
$page = max(1, (int) $request->input('page', 1));
|
||||||
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
||||||
|
|
||||||
$levels = CustomerLevelModel::query()
|
$levels = $this->enabledLevels();
|
||||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
|
||||||
->orderBy('sort')
|
|
||||||
->get(['id', 'name']);
|
|
||||||
|
|
||||||
$rows = $products->getCollection()->map(function (ProductModel $product) use ($levels) {
|
$rows = $products->getCollection()->map(static function (ProductModel $product) use ($levels) {
|
||||||
$priceMap = $product->prices->keyBy('level_id');
|
|
||||||
$row = [
|
$row = [
|
||||||
'id' => $product->id,
|
'id' => $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
@@ -183,31 +216,27 @@ class ProductController extends BaseController
|
|||||||
'cost_price' => (float) $product->cost_price,
|
'cost_price' => (float) $product->cost_price,
|
||||||
];
|
];
|
||||||
foreach ($levels as $level) {
|
foreach ($levels as $level) {
|
||||||
$price = $priceMap[$level->id] ?? null;
|
$row['price_' . $level->id] = $product->cost_price > 0
|
||||||
$row['price_' . $level->id] = $price
|
? (float) CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
? (float) ProductPriceModel::calcActualPrice(
|
|
||||||
(int) $price->price_type,
|
|
||||||
$price->price,
|
|
||||||
$price->percent,
|
|
||||||
$product->cost_price,
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
$row['price_type_' . $level->id] = $price ? (int) $price->price_type : ProductPriceModel::PRICE_TYPE_FIXED;
|
|
||||||
$row['percent_' . $level->id] = $price ? (float) $price->percent : 0;
|
|
||||||
}
|
}
|
||||||
return $row;
|
return $row;
|
||||||
});
|
});
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'levels' => $levels->toArray(),
|
'levels' => $levels->map(static fn (CustomerLevelModel $level) => [
|
||||||
|
'id' => $level->id,
|
||||||
|
'name' => $level->name,
|
||||||
|
'percent' => (float) $level->percent,
|
||||||
|
])->values()->all(),
|
||||||
'rows' => $rows->values()->toArray(),
|
'rows' => $rows->values()->toArray(),
|
||||||
'total' => $products->total(),
|
'total' => $products->total(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
|
* A2 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入,
|
||||||
* 写完后给受影响门店生成 Notice(type=price)
|
* 写完后给全部正常门店的用户生成 Notice(type=price)
|
||||||
*/
|
*/
|
||||||
#[PutRoute('/batchPrice', 'batchPrice')]
|
#[PutRoute('/batchPrice', 'batchPrice')]
|
||||||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||||||
@@ -216,65 +245,30 @@ class ProductController extends BaseController
|
|||||||
|
|
||||||
DB::transaction(function () use ($updates) {
|
DB::transaction(function () use ($updates) {
|
||||||
$productIds = [];
|
$productIds = [];
|
||||||
$levelIds = [];
|
|
||||||
foreach ($updates as $row) {
|
foreach ($updates as $row) {
|
||||||
$productId = (int) $row['product_id'];
|
$productId = (int) $row['product_id'];
|
||||||
$productIds[$productId] = true;
|
$productIds[$productId] = true;
|
||||||
|
|
||||||
// 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行)
|
|
||||||
if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) {
|
|
||||||
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分支2/3:等级价格行(固定价或成本百分比,按 price_type 区分)
|
|
||||||
if (isset($row['level_id'])) {
|
|
||||||
$levelId = (int) $row['level_id'];
|
|
||||||
$levelIds[$levelId] = true;
|
|
||||||
ProductPriceModel::updateOrCreate(
|
|
||||||
['product_id' => $productId, 'level_id' => $levelId],
|
|
||||||
[
|
|
||||||
'price' => $row['price'] ?? 0,
|
|
||||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
|
||||||
'percent' => $row['percent'] ?? 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级
|
|
||||||
$percentLevelIds = ProductPriceModel::query()
|
|
||||||
->whereIn('product_id', array_keys($productIds))
|
|
||||||
->where('price_type', ProductPriceModel::PRICE_TYPE_PERCENT)
|
|
||||||
->pluck('level_id')
|
|
||||||
->merge($levelIds)
|
|
||||||
->unique()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$productNames = ProductModel::whereIn('id', array_keys($productIds))
|
$productNames = ProductModel::whereIn('id', array_keys($productIds))
|
||||||
->pluck('name')
|
->pluck('name')
|
||||||
->implode('、');
|
->implode('、');
|
||||||
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
|
||||||
|
|
||||||
// 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户
|
// 成本价变更影响所有等级的售价:通知全部正常门店
|
||||||
$userIds = UserModel::query()
|
$storeIds = StoreModel::query()
|
||||||
->where('status', UserModel::STATUS_NORMAL)
|
|
||||||
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
|
||||||
$q->select('id')
|
|
||||||
->from('store')
|
|
||||||
->where('status', StoreModel::STATUS_NORMAL)
|
->where('status', StoreModel::STATUS_NORMAL)
|
||||||
->whereIn('level_id', $percentLevelIds);
|
|
||||||
})
|
|
||||||
->pluck('id');
|
->pluck('id');
|
||||||
|
|
||||||
foreach ($userIds as $userId) {
|
foreach ($storeIds as $storeId) {
|
||||||
NoticeModel::create([
|
NoticeModel::create([
|
||||||
'user_id' => $userId,
|
'store_id' => $storeId,
|
||||||
'type' => NoticeModel::TYPE_PRICE,
|
'type' => NoticeModel::TYPE_PRICE,
|
||||||
'title' => '商品价格变更',
|
'title' => '商品价格变更',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'data' => [
|
'data' => [
|
||||||
'product_ids' => array_keys($productIds),
|
'product_ids' => array_keys($productIds),
|
||||||
'level_ids' => array_keys($levelIds),
|
|
||||||
],
|
],
|
||||||
'is_read' => NoticeModel::UNREAD,
|
'is_read' => NoticeModel::UNREAD,
|
||||||
]);
|
]);
|
||||||
@@ -298,4 +292,17 @@ class ProductController extends BaseController
|
|||||||
->toArray();
|
->toArray();
|
||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用中的客户等级(列表/矩阵共用的等级列来源)
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int, CustomerLevelModel>
|
||||||
|
*/
|
||||||
|
private function enabledLevels(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return CustomerLevelModel::query()
|
||||||
|
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||||
|
->orderBy('sort')
|
||||||
|
->get(['id', 'name', 'percent']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,31 @@ namespace App\Http\Controllers\Purchase;
|
|||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Exports\PurchaseOrderExport;
|
use App\Exports\PurchaseOrderExport;
|
||||||
|
use App\Exports\PurchaseStoreExport;
|
||||||
|
use App\Exports\PurchaseSupplierExport;
|
||||||
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
|
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
|
||||||
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
||||||
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
||||||
|
use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\PurchaseItemCheckModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
use App\Models\StoreOrderModel;
|
use App\Models\StoreOrderModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
use App\Services\BillGenerateService;
|
use App\Services\BillGenerateService;
|
||||||
use App\Services\ItemImageResolver;
|
use App\Services\ItemImageResolver;
|
||||||
use App\Services\PurchaseGenerateService;
|
use App\Services\PurchaseGenerateService;
|
||||||
|
use App\Services\PurchaseItemService;
|
||||||
|
use App\Services\WeightEstimator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
@@ -46,7 +55,10 @@ class PurchaseOrderController extends BaseController
|
|||||||
{
|
{
|
||||||
$params = $request->all();
|
$params = $request->all();
|
||||||
$pageSize = $params['pageSize'] ?? 10;
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
|
$data = $this->buildSearch($params, PurchaseOrderModel::query()
|
||||||
|
->with('operator:id,nickname')
|
||||||
|
// 应付商品金额 = Σ 已生成门店账单的商品金额(实际口径;未生成账单为 null)
|
||||||
|
->withSum('bills as bill_product_amount', 'product_amount'))
|
||||||
->orderBy('purchase_date', 'desc')
|
->orderBy('purchase_date', 'desc')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize)
|
->paginate($pageSize)
|
||||||
@@ -98,6 +110,8 @@ class PurchaseOrderController extends BaseController
|
|||||||
$quantity = 0;
|
$quantity = 0;
|
||||||
// 采购总重量
|
// 采购总重量
|
||||||
$weight = '0';
|
$weight = '0';
|
||||||
|
// 采购总金额(Σ明细 amount,单价按 金额÷数量 加权)
|
||||||
|
$amount = '0';
|
||||||
// 门店明细
|
// 门店明细
|
||||||
$cellsMap = [];
|
$cellsMap = [];
|
||||||
|
|
||||||
@@ -106,6 +120,7 @@ class PurchaseOrderController extends BaseController
|
|||||||
// 累加总量
|
// 累加总量
|
||||||
$quantity += (int) $item->quantity;
|
$quantity += (int) $item->quantity;
|
||||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||||
|
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||||
|
|
||||||
// 按门店合并
|
// 按门店合并
|
||||||
if (!isset($cellsMap[$storeId])) {
|
if (!isset($cellsMap[$storeId])) {
|
||||||
@@ -124,9 +139,11 @@ class PurchaseOrderController extends BaseController
|
|||||||
'unit' => $first->unit, // 单位
|
'unit' => $first->unit, // 单位
|
||||||
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
||||||
'supplier' => $supplier, // 供应商
|
'supplier' => $supplier, // 供应商
|
||||||
|
'market' => (string) ($product?->market ?? ''), // 市场(取商品档案)
|
||||||
'cost_price' => $first->cost_price, // 成本价
|
'cost_price' => $first->cost_price, // 成本价
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => (float) $weight,
|
'weight' => (float) $weight,
|
||||||
|
'amount' => $amount,
|
||||||
'cells' => $cellsMap,
|
'cells' => $cellsMap,
|
||||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||||
'product_sort' => (int) ($product->sort ?? 9999),
|
'product_sort' => (int) ($product->sort ?? 9999),
|
||||||
@@ -160,6 +177,12 @@ class PurchaseOrderController extends BaseController
|
|||||||
return $row;
|
return $row;
|
||||||
}, $rows),
|
}, $rows),
|
||||||
'bills' => $bills,
|
'bills' => $bills,
|
||||||
|
// 单品「已对账」标记(入库持久化,商品ID列表)
|
||||||
|
'checked_product_ids' => PurchaseItemCheckModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->pluck('product_id')
|
||||||
|
->map(static fn ($v) => (int) $v)
|
||||||
|
->all(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,22 +270,68 @@ class PurchaseOrderController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出采购单 Excel 表格
|
* 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选)
|
||||||
*
|
*
|
||||||
* @throws
|
* @throws
|
||||||
*/
|
*/
|
||||||
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
|
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
|
||||||
public function export(int $id): Response
|
public function export(int $id, Request $request): Response
|
||||||
{
|
{
|
||||||
$purchase = PurchaseOrderModel::find($id);
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
if (empty($purchase)) {
|
if (empty($purchase)) {
|
||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
|
$supplierId = (int) $request->query('supplier_id', 0);
|
||||||
|
|
||||||
return Excel::download(
|
$filename = $purchase->purchase_no . '_采购单.xlsx';
|
||||||
new PurchaseOrderExport($purchase),
|
if ($supplierId > 0) {
|
||||||
$purchase->purchase_no . '_采购单.xlsx',
|
$supplier = SupplierModel::withTrashed()->find($supplierId);
|
||||||
);
|
$filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出)
|
||||||
|
*/
|
||||||
|
#[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])]
|
||||||
|
public function exportStores(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$storeId = (int) $request->query('store_id', 0);
|
||||||
|
|
||||||
|
$filename = $purchase->purchase_no . '_门店购买详情.xlsx';
|
||||||
|
if ($storeId > 0) {
|
||||||
|
$store = StoreModel::withTrashed()->find($storeId);
|
||||||
|
$filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出)
|
||||||
|
*/
|
||||||
|
#[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])]
|
||||||
|
public function exportSuppliers(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$supplierId = (int) $request->query('supplier_id', 0);
|
||||||
|
|
||||||
|
$filename = $purchase->purchase_no . '_供应商采购明细.xlsx';
|
||||||
|
if ($supplierId > 0) {
|
||||||
|
$supplier = SupplierModel::withTrashed()->find($supplierId);
|
||||||
|
$filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,68 +417,194 @@ class PurchaseOrderController extends BaseController
|
|||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = StoreOrderItemModel::query()
|
|
||||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
|
||||||
->where('store_order_item.purchase_id', $purchase->id)
|
|
||||||
->where('store_order_item.store_id', $storeId)
|
|
||||||
->whereNull('store_order.deleted_at')
|
|
||||||
->select('store_order_item.*')
|
|
||||||
->orderBy('store_order_item.id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// 排序键:分类 sort → 商品 sort(与明细矩阵同序)
|
|
||||||
$products = ProductModel::withTrashed()
|
|
||||||
->with('category:id,sort')
|
|
||||||
->whereIn('id', $items->pluck('product_id')->unique())
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($items->groupBy('product_id') as $productId => $group) {
|
|
||||||
$product = $products->get((int) $productId);
|
|
||||||
$first = $group->first();
|
|
||||||
// 采购总数量(包数)
|
|
||||||
$quantity = 0;
|
|
||||||
// 预计金额 = Σ 明细 amount
|
|
||||||
$amount = '0';
|
|
||||||
// 总重量
|
|
||||||
$weight = '0';
|
|
||||||
foreach ($group as $item) {
|
|
||||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
|
||||||
$quantity += (int) $item->quantity;
|
|
||||||
$amount = bcadd($amount, (string) $item->amount, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows[] = [
|
|
||||||
'product_id' => (int) $productId,
|
|
||||||
'product_name' => $first->product_name,
|
|
||||||
'product_spec' => $first->product_spec, // 包规
|
|
||||||
'unit' => $first->unit, // 单位
|
|
||||||
'price' => $quantity > 0 // 加权平均单价
|
|
||||||
? bcdiv($amount, (string) $quantity, 2)
|
|
||||||
: (string) $first->price,
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'amount' => $amount,
|
|
||||||
'weight' => $weight,
|
|
||||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
|
||||||
'product_sort' => (int) ($product->sort ?? 9999),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
usort($rows, static fn (array $a, array $b): int =>
|
|
||||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
|
||||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
|
||||||
|
|
||||||
$store = StoreModel::withTrashed()->find($storeId);
|
$store = StoreModel::withTrashed()->find($storeId);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
||||||
'items' => array_map(static function (array $row): array {
|
'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId),
|
||||||
unset($row['category_sort'], $row['product_sort']);
|
|
||||||
return $row;
|
|
||||||
}, $rows),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单,
|
||||||
|
* 单价按门店等级上浮比例换算,无等级按成本价兜底)
|
||||||
|
*
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
#[PostRoute(route: '/{id}/store/{storeId}/item', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
|
||||||
|
public function storeItemStore(int $id, int $storeId, PurchaseStoreItemRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$this->assertPurchaseEditable($purchase);
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
$productId = (int) $validated['product_id'];
|
||||||
|
$product = ProductModel::find($productId);
|
||||||
|
if ($product === null) {
|
||||||
|
throw new RepositoryException('商品不存在或已被删除,无法添加');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($purchase, $storeId, $product, $validated) {
|
||||||
|
// 该门店在采购单中的最新一笔订单
|
||||||
|
$order = StoreOrderModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
if ($order === null) {
|
||||||
|
throw new RepositoryException('该门店不在此采购单中,无法添加单品');
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicated = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->where('product_id', $product->id)
|
||||||
|
->exists();
|
||||||
|
if ($duplicated) {
|
||||||
|
throw new RepositoryException('该商品已在此门店采购明细中,请直接修改数量');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单价:门店等级上浮换算价(无等级按成本价兜底)
|
||||||
|
$store = StoreModel::withTrashed()->find($storeId);
|
||||||
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
|
$price = $level !== null
|
||||||
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
|
: bcadd((string) $product->cost_price, '0', 2);
|
||||||
|
$quantity = (int) $validated['quantity'];
|
||||||
|
|
||||||
|
$item = StoreOrderItemModel::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'purchase_id' => $purchase->id,
|
||||||
|
'bill_id' => 0,
|
||||||
|
'store_id' => $storeId,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'category_id' => (int) $product->category_id,
|
||||||
|
'supplier_id' => (int) $product->supplier_id,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'product_spec' => $product->spec,
|
||||||
|
'unit' => (string) $product->unit,
|
||||||
|
'price' => $price,
|
||||||
|
'image_ids' => implode(',', (array) $product->image_ids),
|
||||||
|
'content' => (string) $product->content,
|
||||||
|
'shelf_life' => (int) $product->shelf_life,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
// 未传称重时按订货量 × 规格预填参考重量
|
||||||
|
'weight' => isset($validated['weight'])
|
||||||
|
? bcadd((string) $validated['weight'], '0', 3)
|
||||||
|
: WeightEstimator::estimate((string) $product->spec, (string) $product->unit, (string) $quantity),
|
||||||
|
'amount' => bcmul($price, (string) $quantity, 2),
|
||||||
|
'cost_price' => (string) $product->cost_price,
|
||||||
|
'remark' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(PurchaseItemService::class);
|
||||||
|
$service->recalcOrder($order->id);
|
||||||
|
$service->recalcPurchase($purchase->id);
|
||||||
|
|
||||||
|
return $this->success(['id' => $item->id], '已添加单品');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情:修改单品(数量/称重/单价);同门店同商品多笔订单明细时合并到最早一条
|
||||||
|
*
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
#[PutRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||||
|
public function storeItemUpdate(int $id, int $storeId, int $productId, PurchaseStoreItemRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$this->assertPurchaseEditable($purchase);
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($purchase, $storeId, $productId, $validated) {
|
||||||
|
$items = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
throw new RepositoryException('该门店在此采购单中无此商品明细');
|
||||||
|
}
|
||||||
|
|
||||||
|
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
|
||||||
|
|
||||||
|
// 多笔订单明细合并到最早一条
|
||||||
|
$survivor = $items->first();
|
||||||
|
foreach ($items->skip(1) as $extra) {
|
||||||
|
$extra->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$survivor->quantity = (int) $validated['quantity'];
|
||||||
|
if (isset($validated['price'])) {
|
||||||
|
$survivor->price = bcadd((string) $validated['price'], '0', 2);
|
||||||
|
}
|
||||||
|
if (isset($validated['weight'])) {
|
||||||
|
$survivor->weight = bcadd((string) $validated['weight'], '0', 3);
|
||||||
|
}
|
||||||
|
$survivor->amount = bcmul((string) $survivor->quantity, (string) $survivor->price, 2);
|
||||||
|
$survivor->save();
|
||||||
|
|
||||||
|
$service = app(PurchaseItemService::class);
|
||||||
|
foreach ($affectedOrderIds as $orderId) {
|
||||||
|
$service->recalcOrder((int) $orderId);
|
||||||
|
}
|
||||||
|
$service->recalcPurchase($purchase->id);
|
||||||
|
|
||||||
|
return $this->success([], '单品已更新');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购买详情:移除单品(该门店此商品的全部订货明细一并删除)
|
||||||
|
*
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
#[DeleteRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||||
|
public function storeItemDelete(int $id, int $storeId, int $productId): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$this->assertPurchaseEditable($purchase);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($purchase, $storeId, $productId) {
|
||||||
|
$items = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
throw new RepositoryException('该门店在此采购单中无此商品明细');
|
||||||
|
}
|
||||||
|
|
||||||
|
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(PurchaseItemService::class);
|
||||||
|
foreach ($affectedOrderIds as $orderId) {
|
||||||
|
$service->recalcOrder((int) $orderId);
|
||||||
|
}
|
||||||
|
$service->recalcPurchase($purchase->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->success([], '已移除单品');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单生成预览
|
* 账单生成预览
|
||||||
*/
|
*/
|
||||||
@@ -428,8 +623,9 @@ class PurchaseOrderController extends BaseController
|
|||||||
->get(['id', 'store_id', 'total_amount']);
|
->get(['id', 'store_id', 'total_amount']);
|
||||||
|
|
||||||
$stores = StoreModel::withTrashed()
|
$stores = StoreModel::withTrashed()
|
||||||
|
->with('level:id,name,percent')
|
||||||
->whereIn('id', $orders->pluck('store_id')->unique())
|
->whereIn('id', $orders->pluck('store_id')->unique())
|
||||||
->get(['id', 'name'])
|
->get(['id', 'name', 'level_id'])
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$bills = BillModel::query()
|
$bills = BillModel::query()
|
||||||
@@ -447,9 +643,13 @@ class PurchaseOrderController extends BaseController
|
|||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
$bill = $bills->get((int) $storeId);
|
$bill = $bills->get((int) $storeId);
|
||||||
|
$store = $stores->get((int) $storeId);
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'store_id' => (int) $storeId,
|
'store_id' => (int) $storeId,
|
||||||
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
|
'store_name' => $store->name ?? ('门店#' . $storeId),
|
||||||
|
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
|
||||||
|
'level_name' => $store?->level?->name,
|
||||||
|
'level_percent' => (string) ($store?->level?->percent ?? '0'),
|
||||||
'order_count' => $storeOrders->count(),
|
'order_count' => $storeOrders->count(),
|
||||||
'product_amount' => $productAmount,
|
'product_amount' => $productAmount,
|
||||||
'box_price' => $boxPrice,
|
'box_price' => $boxPrice,
|
||||||
@@ -489,7 +689,9 @@ class PurchaseOrderController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品行修改:品名/供应商/包规/单位/成本
|
* 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步)
|
||||||
|
* 成本价变化时,按各门店客户等级的上浮比例重算明细单价与金额(无等级按成本价兜底),
|
||||||
|
* 并级联重算涉及订单与采购单汇总
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||||
@@ -499,12 +701,11 @@ class PurchaseOrderController extends BaseController
|
|||||||
if (empty($purchase)) {
|
if (empty($purchase)) {
|
||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
$this->assertPurchaseEditable($purchase);
|
||||||
throw new RepositoryException('采购单已完成,不允许修改明细');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($purchase, $productId, $validated) {
|
||||||
$items = StoreOrderItemModel::query()
|
$items = StoreOrderItemModel::query()
|
||||||
->where('purchase_id', $purchase->id)
|
->where('purchase_id', $purchase->id)
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
@@ -513,17 +714,64 @@ class PurchaseOrderController extends BaseController
|
|||||||
if ($items->isEmpty()) {
|
if ($items->isEmpty()) {
|
||||||
throw new RepositoryException('该采购单下无此商品的订货明细');
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||||
}
|
}
|
||||||
foreach ($items as $item) {
|
|
||||||
$item->fill($validated)->save();
|
// 成本价是否变化(未变化时保留明细单价,避免覆盖单元格/单品的手工改价)
|
||||||
|
$newCost = bcadd((string) $validated['cost_price'], '0', 2);
|
||||||
|
$costChanged = bccomp($newCost, bcadd((string) $items->first()->cost_price, '0', 2), 2) !== 0;
|
||||||
|
|
||||||
|
// 成本变化 → 按门店等级上浮比例重算单价(一次性取回涉及门店的等级,避免逐行查询)
|
||||||
|
$levelPercents = [];
|
||||||
|
if ($costChanged) {
|
||||||
|
$levelPercents = StoreModel::withTrashed()
|
||||||
|
->with('level:id,percent')
|
||||||
|
->whereIn('id', $items->pluck('store_id')->unique())
|
||||||
|
->get(['id', 'level_id'])
|
||||||
|
->mapWithKeys(static fn (StoreModel $store) => [
|
||||||
|
$store->id => (float) ($store->level?->percent ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重算采购单成本
|
foreach ($items as $item) {
|
||||||
$query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id);
|
$item->fill($validated);
|
||||||
$purchase->total_quantity = $query->sum('quantity');
|
if ($costChanged) {
|
||||||
$purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price'));
|
$item->price = CustomerLevelModel::calcLevelPrice(
|
||||||
$purchase->save();
|
$newCost,
|
||||||
|
$levelPercents[(int) $item->store_id] ?? 0,
|
||||||
|
);
|
||||||
|
$item->amount = bcmul($item->price, (string) $item->quantity, 2);
|
||||||
|
}
|
||||||
|
$item->save();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细');
|
// 同步保存到商品档案(软删除商品跳过,明细照常更新)
|
||||||
|
$product = ProductModel::find($productId);
|
||||||
|
$productSynced = $product !== null;
|
||||||
|
if ($productSynced) {
|
||||||
|
$product->update([
|
||||||
|
'name' => $validated['product_name'],
|
||||||
|
'supplier_id' => $validated['supplier_id'],
|
||||||
|
'spec' => $validated['product_spec'],
|
||||||
|
'unit' => $validated['unit'],
|
||||||
|
'cost_price' => $validated['cost_price'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(PurchaseItemService::class);
|
||||||
|
// 成本变化会引起明细金额变动,涉及订单需逐一重算
|
||||||
|
if ($costChanged) {
|
||||||
|
foreach ($items->pluck('order_id')->unique() as $orderId) {
|
||||||
|
$service->recalcOrder((int) $orderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$service->recalcPurchase($purchase->id);
|
||||||
|
|
||||||
|
$message = '已同步 ' . $items->count() . ' 条订货明细'
|
||||||
|
. ($productSynced ? '与商品档案' : ';商品已删除,档案未同步')
|
||||||
|
. ($costChanged ? ';门店单价已按等级上浮比例重算' : '');
|
||||||
|
|
||||||
|
return $this->success(['count' => $items->count()], $message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,31 +792,128 @@ class PurchaseOrderController extends BaseController
|
|||||||
if (empty($purchase)) {
|
if (empty($purchase)) {
|
||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
$this->assertPurchaseEditable($purchase);
|
||||||
throw new RepositoryException('采购单已完成,不允许修改明细');
|
|
||||||
}
|
|
||||||
|
|
||||||
$item->quantity = $validated['quantity'];
|
$item->quantity = $validated['quantity'];
|
||||||
$item->price = $validated['price'];
|
$item->price = $validated['price'];
|
||||||
if (array_key_exists('weight', $validated)) {
|
if (array_key_exists('weight', $validated)) {
|
||||||
$item->weight = bcadd((string) $validated['weight'], '0', 3);
|
$item->weight = bcadd((string) $validated['weight'], '0', 3);
|
||||||
}
|
}
|
||||||
$item->amount = bcmul($validated['quantity'], $validated['price'], 3);
|
$item->amount = bcmul((string) $validated['quantity'], (string) $validated['price'], 2);
|
||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
// 重算订单金额
|
$service = app(PurchaseItemService::class);
|
||||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
$service->recalcOrder((int) $item->order_id);
|
||||||
$order->total_weight = $order->items()->sum('weight');
|
$service->recalcPurchase($purchase->id);
|
||||||
$order->total_amount = $order->items()->sum('amount');
|
|
||||||
$order->save();
|
|
||||||
|
|
||||||
// 重算采购单重量
|
|
||||||
$purchase->total_weight = StoreOrderItemModel::query()
|
|
||||||
->where('purchase_id', $purchase->id)
|
|
||||||
->sum('weight');
|
|
||||||
$purchase->save();
|
|
||||||
|
|
||||||
return $this->success();
|
return $this->success();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换单品「已对账」标记(入库持久化:未标记→标记,已标记→取消;与采购单状态无关,对账期间可反复勾选)
|
||||||
|
*/
|
||||||
|
#[PutRoute(route: '/{id}/check/{productId}', authorize: 'query', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||||
|
public function toggleItemCheck(int $id, int $productId, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
$hasItem = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->exists();
|
||||||
|
if (! $hasItem) {
|
||||||
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||||
|
}
|
||||||
|
|
||||||
|
$marked = PurchaseItemCheckModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->first();
|
||||||
|
if ($marked !== null) {
|
||||||
|
$marked->delete();
|
||||||
|
return $this->success(['checked' => false], '已取消对账标记');
|
||||||
|
}
|
||||||
|
|
||||||
|
PurchaseItemCheckModel::create([
|
||||||
|
'purchase_id' => $purchase->id,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'operator_id' => (int) $request->user()->id,
|
||||||
|
]);
|
||||||
|
return $this->success(['checked' => true], '已标记为已对账');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置单品「已对账」标记(全选用:checked=true 批量标记 / false 批量取消;
|
||||||
|
* 仅处理本采购单内有订货明细的商品,重复标记幂等;与采购单状态无关)
|
||||||
|
*/
|
||||||
|
#[PutRoute(route: '/{id}/check', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||||
|
public function batchItemCheck(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'product_ids' => 'required|array|min:1',
|
||||||
|
'product_ids.*' => 'integer',
|
||||||
|
'checked' => 'required|boolean',
|
||||||
|
], [
|
||||||
|
'product_ids.required' => '请选择商品',
|
||||||
|
'product_ids.min' => '请选择商品',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchase = PurchaseOrderModel::find($id);
|
||||||
|
if (empty($purchase)) {
|
||||||
|
throw new RepositoryException('采购单不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅处理本采购单内有订货明细的商品(无效商品静默忽略)
|
||||||
|
$productIds = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->whereIn('product_id', array_map('intval', $data['product_ids']))
|
||||||
|
->distinct()
|
||||||
|
->pluck('product_id')
|
||||||
|
->map(static fn ($v) => (int) $v);
|
||||||
|
|
||||||
|
if (! (bool) $data['checked']) {
|
||||||
|
PurchaseItemCheckModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->whereIn('product_id', $productIds)
|
||||||
|
->delete();
|
||||||
|
return $this->success(['count' => $productIds->count()], '已取消对账标记');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量标记:跳过已标记行,仅补插缺失行(幂等)
|
||||||
|
$marked = PurchaseItemCheckModel::query()
|
||||||
|
->where('purchase_id', $purchase->id)
|
||||||
|
->whereIn('product_id', $productIds)
|
||||||
|
->pluck('product_id')
|
||||||
|
->map(static fn ($v) => (int) $v);
|
||||||
|
$operatorId = (int) $request->user()->id;
|
||||||
|
$now = now();
|
||||||
|
$rows = $productIds->diff($marked)
|
||||||
|
->map(static fn (int $productId) => [
|
||||||
|
'purchase_id' => $purchase->id,
|
||||||
|
'product_id' => $productId,
|
||||||
|
'operator_id' => $operatorId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
if ($rows !== []) {
|
||||||
|
PurchaseItemCheckModel::insert($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(['count' => $productIds->count()], '已标记为已对账');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单编辑闸:仅进行中(待采购)允许修改明细
|
||||||
|
*/
|
||||||
|
private function assertPurchaseEditable(PurchaseOrderModel $purchase): void
|
||||||
|
{
|
||||||
|
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||||
|
throw new RepositoryException('采购单已完成,不允许修改明细');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
@@ -35,7 +36,7 @@ class PaymentController extends BaseController
|
|||||||
$params = $request->all();
|
$params = $request->all();
|
||||||
$pageSize = $params['pageSize'] ?? 10;
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
$data = $this->buildSearch($params, PaymentModel::query()
|
$data = $this->buildSearch($params, PaymentModel::query()
|
||||||
->with(['store:id,name', 'user:id,nickname', 'auditor:id,nickname'])
|
->with(['store:id,name', 'auditor:id,nickname'])
|
||||||
->withCount('bills'))
|
->withCount('bills'))
|
||||||
->orderBy('status')
|
->orderBy('status')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
@@ -48,14 +49,14 @@ class PaymentController extends BaseController
|
|||||||
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||||
public function detail(int $id): JsonResponse
|
public function detail(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$payment = PaymentModel::with(['store:id,name,contact,phone', 'user:id,nickname', 'auditor:id,nickname'])->find($id);
|
$payment = PaymentModel::with(['store:id,name,contact,phone', 'auditor:id,nickname'])->find($id);
|
||||||
if (empty($payment)) {
|
if (empty($payment)) {
|
||||||
throw new RepositoryException('支付记录不存在');
|
throw new RepositoryException('支付记录不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
$bills = $payment->bills()
|
$bills = $payment->bills()
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status'])
|
->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'after_sale', 'total_amount', 'status'])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$data = $payment->toArray();
|
$data = $payment->toArray();
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Requests\UserUpdateInfoRequest;
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Modules\Common\Trait\RequestJson;
|
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
|
||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|
||||||
|
|
||||||
#[RequestAttribute('/api/user', authGuard: 'users')]
|
|
||||||
class UserController
|
|
||||||
{
|
|
||||||
use RequestJson;
|
|
||||||
protected array $noPermission = ['refreshToken'];
|
|
||||||
|
|
||||||
#[GetRoute]
|
|
||||||
public function getUserInfo(): JsonResponse
|
|
||||||
{
|
|
||||||
$info = auth()->user();
|
|
||||||
return $this->success(compact('info'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PostRoute('/logout')]
|
|
||||||
public function logout(): JsonResponse
|
|
||||||
{
|
|
||||||
$user_id = auth('users')->id();
|
|
||||||
$model = new UserModel;
|
|
||||||
if ($model->logout($user_id)) {
|
|
||||||
return $this->success('退出登录成功');
|
|
||||||
} else {
|
|
||||||
return $this->error($model->getErrorMsg());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PutRoute]
|
|
||||||
public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
UserModel::where('user_id', auth('user')->id())->update($request->validated());
|
|
||||||
|
|
||||||
return $this->error('更新成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PostRoute('/setPwd')]
|
|
||||||
public function setPassword(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'oldPassword' => 'required|string|max:20',
|
|
||||||
'newPassword' => 'required|string|min:6|max:20',
|
|
||||||
'rePassword' => 'required|same:newPassword',
|
|
||||||
]);
|
|
||||||
$user_id = auth('user')->id();
|
|
||||||
$user = UserModel::query()->find($user_id);
|
|
||||||
if (! password_verify($data['oldPassword'], $user['password'])) {
|
|
||||||
return $this->error('旧密码不正确!');
|
|
||||||
}
|
|
||||||
$user->password = password_hash($data['newPassword'], PASSWORD_DEFAULT);
|
|
||||||
if ($user->save()) {
|
|
||||||
return $this->success('更新成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->error('更新失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 批量添加 验证
|
||||||
|
*/
|
||||||
|
class SpecialBatchAddRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids' => 'required|array|min:1',
|
||||||
|
'product_ids.*' => 'integer|distinct|exists:product,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids.required' => '请选择要添加的商品',
|
||||||
|
'product_ids.min' => '请选择要添加的商品',
|
||||||
|
'product_ids.*.integer' => '商品 ID 格式错误',
|
||||||
|
'product_ids.*.distinct' => '存在重复的商品',
|
||||||
|
'product_ids.*.exists' => '所选商品不存在',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 编辑 验证(仅排序与状态可编辑,商品不可更换)
|
||||||
|
*/
|
||||||
|
class SpecialFormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort' => 'nullable|integer|min:0',
|
||||||
|
'status' => 'required|integer|in:0,1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort.min' => '排序不能小于 0',
|
||||||
|
'status.in' => '状态只能是 0 或 1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:50', $unique],
|
'name' => ['required', 'string', 'max:50', $unique],
|
||||||
|
'percent' => 'nullable|numeric|min:0|max:999.99',
|
||||||
'sort' => 'nullable|integer',
|
'sort' => 'nullable|integer',
|
||||||
'status' => 'nullable|integer|in:0,1',
|
'status' => 'nullable|integer|in:0,1',
|
||||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||||
@@ -35,6 +36,9 @@ class CustomerLevelFormRequest extends BaseFormRequest
|
|||||||
'name.required' => '等级名称不能为空',
|
'name.required' => '等级名称不能为空',
|
||||||
'name.max' => '等级名称最长 50 个字符',
|
'name.max' => '等级名称最长 50 个字符',
|
||||||
'name.unique' => '等级名称已存在',
|
'name.unique' => '等级名称已存在',
|
||||||
|
'percent.numeric' => '价格上浮比例必须为数字',
|
||||||
|
'percent.min' => '价格上浮比例不能小于 0',
|
||||||
|
'percent.max' => '价格上浮比例不能超过 999.99',
|
||||||
'status.in' => '状态值不正确',
|
'status.in' => '状态值不正确',
|
||||||
'icon_id.exists' => '请重新上传图片'
|
'icon_id.exists' => '请重新上传图片'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Customer;
|
|
||||||
|
|
||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 小程序用户绑定 验证(绑定门店)
|
|
||||||
*/
|
|
||||||
class MiniUserBindRequest extends BaseFormRequest
|
|
||||||
{
|
|
||||||
protected $stopOnFirstFailure = true;
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'store_id' => 'required|integer|min:1',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'store_id.required' => '绑定门店时必须选择门店',
|
|
||||||
'store_id.min' => '门店ID不正确',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ namespace App\Http\Requests\Customer;
|
|||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知 创建 验证(user_id 留空 = 全员广播)
|
* 通知 创建 验证(store_id 留空 = 全员广播)
|
||||||
*/
|
*/
|
||||||
class NoticeFormRequest extends BaseFormRequest
|
class NoticeFormRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -13,13 +13,13 @@ class NoticeFormRequest extends BaseFormRequest
|
|||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$this->merge(['user_id' => (int) ($this->input('user_id') ?? 0)]);
|
$this->merge(['store_id' => (int) ($this->input('store_id') ?? 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'required|integer|min:0',
|
'store_id' => 'required|integer|min:0',
|
||||||
'type' => 'required|string|in:order,price,system',
|
'type' => 'required|string|in:order,price,system',
|
||||||
'title' => 'required|string|max:100',
|
'title' => 'required|string|max:100',
|
||||||
'content' => 'nullable|string|max:500',
|
'content' => 'nullable|string|max:500',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use Illuminate\Validation\Rule;
|
|||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店 创建/编辑 验证
|
* 门店 创建/编辑 验证(登录账号唯一;密码创建必填,编辑留空不修改)
|
||||||
*/
|
*/
|
||||||
class StoreFormRequest extends BaseFormRequest
|
class StoreFormRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -14,8 +14,19 @@ class StoreFormRequest extends BaseFormRequest
|
|||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$id = (int) $this->route('id', 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
|
'username' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'min:4',
|
||||||
|
'max:20',
|
||||||
|
'alpha_dash',
|
||||||
|
Rule::unique('store', 'username')->ignore($id),
|
||||||
|
],
|
||||||
|
'password' => ($this->isMethod('post') ? 'required' : 'nullable') . '|string|min:6|max:20',
|
||||||
'level_id' => 'required|integer|exists:customer_level,id',
|
'level_id' => 'required|integer|exists:customer_level,id',
|
||||||
'contact' => 'nullable|string|max:50',
|
'contact' => 'nullable|string|max:50',
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
@@ -31,6 +42,14 @@ class StoreFormRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'name.required' => '门店名称不能为空',
|
'name.required' => '门店名称不能为空',
|
||||||
'name.max' => '门店名称最长 100 个字符',
|
'name.max' => '门店名称最长 100 个字符',
|
||||||
|
'username.required' => '登录账号不能为空',
|
||||||
|
'username.min' => '登录账号至少 4 个字符',
|
||||||
|
'username.max' => '登录账号最长 20 个字符',
|
||||||
|
'username.alpha_dash' => '登录账号只能由字母、数字、中划线、下划线组成',
|
||||||
|
'username.unique' => '登录账号已被使用',
|
||||||
|
'password.required' => '登录密码不能为空',
|
||||||
|
'password.min' => '登录密码至少 6 位',
|
||||||
|
'password.max' => '登录密码最长 20 位',
|
||||||
'level_id.required' => '请选择客户等级',
|
'level_id.required' => '请选择客户等级',
|
||||||
'level_id.exists' => '客户等级不存在',
|
'level_id.exists' => '客户等级不存在',
|
||||||
'payment_cycle_days.integer' => '回款周期必须为整数',
|
'payment_cycle_days.integer' => '回款周期必须为整数',
|
||||||
|
|||||||
@@ -2,18 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Product;
|
namespace App\Http\Requests\Product;
|
||||||
|
|
||||||
use App\Models\ProductPriceModel;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量调价 验证(A2 价格矩阵编辑提交)
|
* 批量调价 验证(A2 价格矩阵编辑提交)
|
||||||
*
|
*
|
||||||
* updates 每行支持三类更新(可混合同一行):
|
* 等级售价 = 成本价 × (100 + 等级上浮比例) / 100,矩阵仅支持批量调整成本价:
|
||||||
* 成本行 {product_id, cost_price}
|
* 成本行 {product_id, cost_price}
|
||||||
* 固定价行 {product_id, level_id, price_type?:0, price}
|
|
||||||
* 百分比行 {product_id, level_id, price_type:1, percent}(price 可选,等价固定价)
|
|
||||||
*/
|
*/
|
||||||
class BatchPriceRequest extends BaseFormRequest
|
class BatchPriceRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -24,54 +19,10 @@ class BatchPriceRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'updates' => 'required|array|min:1',
|
'updates' => 'required|array|min:1',
|
||||||
'updates.*.product_id' => 'required|integer|exists:product,id',
|
'updates.*.product_id' => 'required|integer|exists:product,id',
|
||||||
'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999',
|
'updates.*.cost_price' => 'required|numeric|min:0|max:99999999',
|
||||||
'updates.*.level_id' => 'nullable|integer|exists:customer_level,id',
|
|
||||||
'updates.*.price_type' => 'nullable|integer|in:0,1',
|
|
||||||
'updates.*.price' => 'nullable|numeric|min:0|max:99999999',
|
|
||||||
'updates.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 行级交叉校验(在 after() 内按实际数据逐行判断,避免 required_with* 通配符参数解析不可靠):
|
|
||||||
* - 每行必须至少包含成本价或等级价格更新
|
|
||||||
* - 出现等级字段(price/percent/price_type)时必须带 level_id
|
|
||||||
* - 等级行必须有 price 或 percent
|
|
||||||
* - 成本百分比计价(price_type=1)时上浮百分点必填
|
|
||||||
*/
|
|
||||||
public function after(): Closure
|
|
||||||
{
|
|
||||||
return function (Validator $validator): void {
|
|
||||||
$data = (array) $validator->getData();
|
|
||||||
foreach ((array) ($data['updates'] ?? []) as $index => $row) {
|
|
||||||
$row = (array) $row;
|
|
||||||
$hasCost = array_key_exists('cost_price', $row) && $row['cost_price'] !== null && $row['cost_price'] !== '';
|
|
||||||
$hasLevel = isset($row['level_id']);
|
|
||||||
$hasPrice = array_key_exists('price', $row) && $row['price'] !== null && $row['price'] !== '';
|
|
||||||
$hasPercent = array_key_exists('percent', $row) && $row['percent'] !== null && $row['percent'] !== '';
|
|
||||||
$hasType = array_key_exists('price_type', $row);
|
|
||||||
|
|
||||||
if (! $hasCost && ! $hasLevel) {
|
|
||||||
$validator->errors()->add("updates.{$index}", '调价行缺少成本价或等级价格');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (($hasPrice || $hasPercent || $hasType) && ! $hasLevel) {
|
|
||||||
$validator->errors()->add("updates.{$index}.level_id", '等级价格行缺少客户等级');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($hasLevel && ! $hasPrice && ! $hasPercent) {
|
|
||||||
$validator->errors()->add("updates.{$index}", '等级价格行缺少单价或上浮百分点');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
|
||||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT && ! $hasPercent) {
|
|
||||||
$validator->errors()->add("updates.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -79,15 +30,9 @@ class BatchPriceRequest extends BaseFormRequest
|
|||||||
'updates.min' => '请至少提交一条价格调整',
|
'updates.min' => '请至少提交一条价格调整',
|
||||||
'updates.*.product_id.required' => '调价行缺少商品',
|
'updates.*.product_id.required' => '调价行缺少商品',
|
||||||
'updates.*.product_id.exists' => '商品不存在',
|
'updates.*.product_id.exists' => '商品不存在',
|
||||||
|
'updates.*.cost_price.required' => '调价行缺少成本价',
|
||||||
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
||||||
'updates.*.cost_price.min' => '成本价不能小于 0',
|
'updates.*.cost_price.min' => '成本价不能小于 0',
|
||||||
'updates.*.level_id.exists' => '客户等级不存在',
|
|
||||||
'updates.*.price_type.in' => '计价类型不正确',
|
|
||||||
'updates.*.price.numeric' => '单价必须为数字',
|
|
||||||
'updates.*.price.min' => '单价不能小于 0',
|
|
||||||
'updates.*.percent.numeric' => '上浮百分点必须为数字',
|
|
||||||
'updates.*.percent.min' => '上浮百分点不能小于 0',
|
|
||||||
'updates.*.percent.max' => '上浮百分点不能超过 999.99',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Requests\Product;
|
namespace App\Http\Requests\Product;
|
||||||
|
|
||||||
use App\Models\ProductCategoryModel;
|
use App\Models\ProductCategoryModel;
|
||||||
use App\Models\ProductPriceModel;
|
|
||||||
use App\Models\SupplierModel;
|
use App\Models\SupplierModel;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Validation\Rules\Exists;
|
use Illuminate\Validation\Rules\Exists;
|
||||||
@@ -12,7 +11,7 @@ use Modules\Common\Http\Requests\BaseFormRequest;
|
|||||||
use Modules\SystemTool\Models\SysFileModel;
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
|
* 商品档案 创建/编辑 验证(售价按客户等级上浮比例换算,不再维护等级价格行)
|
||||||
*/
|
*/
|
||||||
class ProductFormRequest extends BaseFormRequest
|
class ProductFormRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -23,9 +22,11 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
|
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
|
||||||
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
|
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
|
||||||
|
'market' => 'nullable|string|max:50',
|
||||||
'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',
|
||||||
@@ -35,16 +36,11 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
'status' => 'nullable|integer|in:0,1',
|
'status' => 'nullable|integer|in:0,1',
|
||||||
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||||
'remark' => 'nullable|string|max:255',
|
'remark' => 'nullable|string|max:255',
|
||||||
'prices' => 'nullable|array',
|
|
||||||
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
|
|
||||||
'prices.*.price_type' => 'nullable|integer|in:0,1',
|
|
||||||
'prices.*.price' => 'required|numeric|min:0|max:99999999',
|
|
||||||
'prices.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交叉校验:商品只能挂在末级分类;按成本百分比计价(price_type=1)时上浮百分点必填
|
* 交叉校验:商品只能挂在末级分类
|
||||||
* (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator)
|
* (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator)
|
||||||
*/
|
*/
|
||||||
public function after(): Closure
|
public function after(): Closure
|
||||||
@@ -55,13 +51,6 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
|
if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
|
||||||
$validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
|
$validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
|
||||||
}
|
}
|
||||||
foreach ((array) ($data['prices'] ?? []) as $index => $row) {
|
|
||||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
|
||||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT
|
|
||||||
&& (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) {
|
|
||||||
$validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +63,8 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
'category_id.exists' => '商品分类不存在',
|
'category_id.exists' => '商品分类不存在',
|
||||||
'supplier_id.exists' => '供应商不存在',
|
'supplier_id.exists' => '供应商不存在',
|
||||||
'status.in' => '状态值不正确',
|
'status.in' => '状态值不正确',
|
||||||
'prices.*.level_id.required' => '价格行缺少客户等级',
|
|
||||||
'prices.*.level_id.exists' => '客户等级不存在',
|
|
||||||
'prices.*.price.required' => '价格行缺少单价',
|
|
||||||
'prices.*.price.numeric' => '单价必须为数字',
|
|
||||||
'prices.*.price.min' => '单价不能小于 0',
|
|
||||||
'cost_price.numeric' => '成本价必须为数字',
|
'cost_price.numeric' => '成本价必须为数字',
|
||||||
'cost_price.min' => '成本价不能小于 0',
|
'cost_price.min' => '成本价不能小于 0',
|
||||||
'prices.*.price_type.in' => '计价类型不正确',
|
|
||||||
'prices.*.percent.numeric' => '上浮百分点必须为数字',
|
|
||||||
'prices.*.percent.min' => '上浮百分点不能小于 0',
|
|
||||||
'prices.*.percent.max' => '上浮百分点不能超过 999.99',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace App\Http\Requests\Purchase;
|
|||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改;
|
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量与售后金额/备注,金额由系统汇总不可修改;
|
||||||
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额)
|
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额;售后金额可正负,计入总金额)
|
||||||
*/
|
*/
|
||||||
class PurchaseBillGenerateRequest extends BaseFormRequest
|
class PurchaseBillGenerateRequest extends BaseFormRequest
|
||||||
{
|
{
|
||||||
@@ -20,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|numeric',
|
||||||
|
'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.numeric' => '售后金额格式错误(可正负)',
|
||||||
|
'stores.*.remark.max' => '备注最长 255 个字符',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Purchase;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单门店单品 新增/修改 验证(amount = 数量×单价 由后端重算)
|
||||||
|
*/
|
||||||
|
class PurchaseStoreItemRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
if ($this->isUpdate()) {
|
||||||
|
return [
|
||||||
|
'quantity' => 'required|integer|min:0',
|
||||||
|
'price' => 'nullable|numeric|min:0',
|
||||||
|
'weight' => 'nullable|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product_id' => 'required|integer|exists:product,id',
|
||||||
|
'quantity' => 'required|integer|min:1',
|
||||||
|
'weight' => 'nullable|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_id.required' => '请选择商品',
|
||||||
|
'product_id.exists' => '商品不存在或已删除',
|
||||||
|
'quantity.required' => '采购数量不能为空',
|
||||||
|
'quantity.integer' => '采购数量必须为整数',
|
||||||
|
'quantity.min' => '采购数量不能小于 0',
|
||||||
|
'price.numeric' => '单价必须为数字',
|
||||||
|
'price.min' => '单价不能小于 0',
|
||||||
|
'weight.numeric' => '称重必须为数字',
|
||||||
|
'weight.min' => '称重不能小于 0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UserRegisterRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'username' => 'required|min:4|alphaDash',
|
|
||||||
'password' => 'required|min:4|alphaDash',
|
|
||||||
'rePassword' => 'required|min:4|same:password',
|
|
||||||
'email' => 'required|email',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UserUpdateInfoRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'username' => 'required|min:4|max:20',
|
|
||||||
'nickname' => 'required|min:4|max:20',
|
|
||||||
'gender' => 'required',
|
|
||||||
'email' => 'required|email',
|
|
||||||
'avatar_id' => 'required|integer',
|
|
||||||
'mobile' => 'required|regex:/^1[34578]\d{9}$/',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Imports;
|
||||||
|
|
||||||
|
use App\Exports\ProductExport;
|
||||||
|
use App\Models\ProductCategoryModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品档案 Excel 导入:整表校验,任何一行有错则全部不导入(返回全部错误行号+原因)。
|
||||||
|
* 规则:品名必填;分类按名称/「父分类/子分类」路径匹配末级分类;供应商按名称匹配、不存在自动创建;
|
||||||
|
* 导入一律新增商品,不做匹配更新。
|
||||||
|
*/
|
||||||
|
class ProductImport implements ToCollection
|
||||||
|
{
|
||||||
|
/** 单次导入数据行上限(防误传超大文件) */
|
||||||
|
private const int MAX_ROWS = 1000;
|
||||||
|
|
||||||
|
/** @var array<int, array{row: int, message: string}> 校验错误行(Excel 行号,1 起) */
|
||||||
|
public array $errors = [];
|
||||||
|
|
||||||
|
/** 成功写入的商品数 */
|
||||||
|
public int $created = 0;
|
||||||
|
|
||||||
|
/** @var array<int, string> 本次自动创建的供应商名称 */
|
||||||
|
public array $suppliersCreated = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maatwebsite 入口:读取首个工作表全部行(第 1 行为列头)
|
||||||
|
*
|
||||||
|
* @param Collection<int, Collection<int, mixed>> $rows
|
||||||
|
*/
|
||||||
|
public function collection(Collection $rows): void
|
||||||
|
{
|
||||||
|
$header = $rows->first();
|
||||||
|
if (! $this->isValidHeader($header)) {
|
||||||
|
$this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试'];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataRows = $rows->slice(1)->values();
|
||||||
|
if ($dataRows->count() > self::MAX_ROWS) {
|
||||||
|
$this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入'];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = ProductCategoryModel::all();
|
||||||
|
$leafIds = $this->leafCategoryIds($categories);
|
||||||
|
|
||||||
|
$parsed = [];
|
||||||
|
foreach ($dataRows as $index => $row) {
|
||||||
|
$excelRow = $index + 2; // Excel 行号(1 起,含列头行)
|
||||||
|
$cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray());
|
||||||
|
|
||||||
|
// 整行空白视为空行跳过
|
||||||
|
if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->parseRow($cells, $categories, $leafIds);
|
||||||
|
if ($result['errors'] !== []) {
|
||||||
|
foreach ($result['errors'] as $message) {
|
||||||
|
$this->errors[] = ['row' => $excelRow, 'message' => $message];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$parsed[] = $result['data'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->errors !== []) {
|
||||||
|
return; // 整表校验:有错一行不写
|
||||||
|
}
|
||||||
|
if ($parsed === []) {
|
||||||
|
$this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行'];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeRows($parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验一行数据;全部合法时返回写入字段
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $cells
|
||||||
|
* @param Collection<int, ProductCategoryModel> $categories
|
||||||
|
* @param array<int, true> $leafIds
|
||||||
|
* @return array{data: array<string, mixed>, errors: array<int, string>}
|
||||||
|
*/
|
||||||
|
private function parseRow(array $cells, Collection $categories, array $leafIds): array
|
||||||
|
{
|
||||||
|
[$name, $categoryName, $supplierName, $market, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] =
|
||||||
|
array_pad(array_slice($cells, 0, 12), 12, null);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$name = (string) $name;
|
||||||
|
if ($name === '') {
|
||||||
|
$errors[] = '品名不能为空';
|
||||||
|
} elseif (mb_strlen($name) > 100) {
|
||||||
|
$errors[] = '品名最长 100 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryId = 0;
|
||||||
|
$categoryName = (string) $categoryName;
|
||||||
|
if ($categoryName === '') {
|
||||||
|
$errors[] = '分类不能为空';
|
||||||
|
} else {
|
||||||
|
$categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds);
|
||||||
|
if ($categoryId === 0) {
|
||||||
|
$errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplierName = (string) $supplierName;
|
||||||
|
if (mb_strlen($supplierName) > 100) {
|
||||||
|
$errors[] = '供应商名称最长 100 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
$market = (string) $market;
|
||||||
|
if (mb_strlen($market) > 50) {
|
||||||
|
$errors[] = '市场最长 50 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = (string) $spec;
|
||||||
|
if (mb_strlen($spec) > 100) {
|
||||||
|
$errors[] = '规格/包规最长 100 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
$unit = (string) $unit === '' ? '斤' : (string) $unit;
|
||||||
|
if (mb_strlen($unit) > 20) {
|
||||||
|
$errors[] = '单位最长 20 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cost = $this->parseDecimal($costPrice, 0, 99999999);
|
||||||
|
if ($cost === null) {
|
||||||
|
$errors[] = '成本价必须为不小于 0 的数字';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = $this->parseInteger($sort);
|
||||||
|
if ($sort === null) {
|
||||||
|
$errors[] = '排序必须为不小于 0 的整数';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stock = $this->parseInteger($stock);
|
||||||
|
if ($stock === null) {
|
||||||
|
$errors[] = '库存必须为不小于 0 的整数';
|
||||||
|
}
|
||||||
|
|
||||||
|
$shelfLife = $this->parseInteger($shelfLife);
|
||||||
|
if ($shelfLife === null) {
|
||||||
|
$errors[] = '保质期必须为不小于 0 的整数';
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->parseStatus($statusText);
|
||||||
|
if ($status === null) {
|
||||||
|
$errors[] = '状态只能填「上架」或「下架」';
|
||||||
|
}
|
||||||
|
|
||||||
|
$remark = (string) $remark;
|
||||||
|
if (mb_strlen($remark) > 255) {
|
||||||
|
$errors[] = '备注最长 255 个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return ['data' => [], 'errors' => $errors];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => [
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建)
|
||||||
|
'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表
|
||||||
|
'market' => $market,
|
||||||
|
'name' => $name,
|
||||||
|
'spec' => $spec,
|
||||||
|
'unit' => $unit,
|
||||||
|
'image_ids' => '',
|
||||||
|
'content' => '',
|
||||||
|
'sort' => $sort,
|
||||||
|
'shelf_life' => $shelfLife,
|
||||||
|
'stock' => $stock,
|
||||||
|
'status' => $status,
|
||||||
|
'cost_price' => $cost,
|
||||||
|
'remark' => $remark,
|
||||||
|
],
|
||||||
|
'errors' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事务写入:自动创建缺失供应商,批量插入商品
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $parsed
|
||||||
|
*/
|
||||||
|
private function writeRows(array $parsed): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($parsed) {
|
||||||
|
// 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建)
|
||||||
|
$supplierIds = SupplierModel::pluck('id', 'name');
|
||||||
|
foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) {
|
||||||
|
if (! isset($supplierIds[$name])) {
|
||||||
|
$supplier = SupplierModel::create(['name' => $name]);
|
||||||
|
$supplierIds[$name] = $supplier->id;
|
||||||
|
$this->suppliersCreated[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$payload = array_map(static function (array $row) use ($supplierIds, $now) {
|
||||||
|
if ($row['supplier_name'] !== '') {
|
||||||
|
$row['supplier_id'] = (int) $supplierIds[$row['supplier_name']];
|
||||||
|
}
|
||||||
|
unset($row['supplier_name']);
|
||||||
|
$row['created_at'] = $now;
|
||||||
|
$row['updated_at'] = $now;
|
||||||
|
return $row;
|
||||||
|
}, $parsed);
|
||||||
|
|
||||||
|
ProductModel::insert($payload);
|
||||||
|
$this->created = count($payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类名称/路径 → 末级分类ID(0 = 解析失败)
|
||||||
|
* 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一
|
||||||
|
*
|
||||||
|
* @param Collection<int, ProductCategoryModel> $categories
|
||||||
|
* @param array<int, true> $leafIds
|
||||||
|
*/
|
||||||
|
private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int
|
||||||
|
{
|
||||||
|
// 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文
|
||||||
|
$segments = array_values(array_filter(array_map(
|
||||||
|
static fn ($segment) => trim($segment),
|
||||||
|
preg_split('#[//\\\\]#u', $name) ?: []
|
||||||
|
), static fn ($segment) => $segment !== ''));
|
||||||
|
|
||||||
|
if ($segments === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($segments) > 1) {
|
||||||
|
// 路径逐级匹配
|
||||||
|
$parentId = 0;
|
||||||
|
$matched = null;
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
$matched = $categories->first(
|
||||||
|
static fn (ProductCategoryModel $category) => $category->name === $segment
|
||||||
|
&& (int) $category->parent_id === $parentId
|
||||||
|
);
|
||||||
|
if ($matched === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$parentId = (int) $matched->id;
|
||||||
|
}
|
||||||
|
return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单名称:仅在末级分类中唯一时命中
|
||||||
|
$leafMatches = $categories->filter(
|
||||||
|
static fn (ProductCategoryModel $category) => $category->name === $segments[0]
|
||||||
|
&& isset($leafIds[(int) $category->id])
|
||||||
|
);
|
||||||
|
if ($leafMatches->count() !== 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (int) $leafMatches->first()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 末级分类ID集合(无任何子分类)
|
||||||
|
*
|
||||||
|
* @param Collection<int, ProductCategoryModel> $categories
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
private function leafCategoryIds(Collection $categories): array
|
||||||
|
{
|
||||||
|
$parentIds = [];
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
if ((int) $category->parent_id > 0) {
|
||||||
|
$parentIds[(int) $category->parent_id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$leafIds = [];
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
if (! isset($parentIds[(int) $category->id])) {
|
||||||
|
$leafIds[(int) $category->id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $leafIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格)
|
||||||
|
*/
|
||||||
|
private function isValidHeader(?Collection $header): bool
|
||||||
|
{
|
||||||
|
if ($header === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray());
|
||||||
|
foreach (ProductExport::HEADERS as $index => $expected) {
|
||||||
|
if (($cells[$index] ?? '') !== $expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析十进制数字(空串取默认值;越界/非数字返回 null)
|
||||||
|
*/
|
||||||
|
private function parseDecimal(mixed $value, float $min, float $max): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = 0;
|
||||||
|
}
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$number = (float) $value;
|
||||||
|
if ($number < $min || $number > $max) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return number_format($number, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析非负整数(空串取 0;非整数/负数返回 null)
|
||||||
|
*/
|
||||||
|
private function parseInteger(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$number = (float) $value;
|
||||||
|
if ($number < 0 || $number !== (float) (int) $number) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (int) $number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析状态(空串默认上架;接受 上架/下架/1/0)
|
||||||
|
*/
|
||||||
|
private function parseStatus(mixed $value): ?int
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
return match ($text) {
|
||||||
|
'', '上架', '1' => ProductModel::STATUS_ON,
|
||||||
|
'下架', '0' => ProductModel::STATUS_OFF,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
@@ -89,6 +90,7 @@ class BillModel extends Model
|
|||||||
'tray_price' => 'decimal:2',
|
'tray_price' => 'decimal:2',
|
||||||
'added_amount' => 'decimal:2',
|
'added_amount' => 'decimal:2',
|
||||||
'total_amount' => 'decimal:2',
|
'total_amount' => 'decimal:2',
|
||||||
|
'after_sale' => 'decimal:2',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
'payment_id' => 'integer',
|
'payment_id' => 'integer',
|
||||||
'paid_at' => 'datetime:Y-m-d H:i:s',
|
'paid_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序购物车模型(门店订货车:按用户归属,同商品唯一行、加购合并数量)
|
* 小程序购物车模型(门店订货车:按门店归属,同商品唯一行、加购合并数量)
|
||||||
*/
|
*/
|
||||||
class CartModel extends Model
|
class CartModel extends Model
|
||||||
{
|
{
|
||||||
@@ -17,23 +17,23 @@ class CartModel extends Model
|
|||||||
protected $primaryKey = 'id';
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'store_id',
|
||||||
'product_id',
|
'product_id',
|
||||||
'quantity',
|
'quantity',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'user_id' => 'integer',
|
'store_id' => 'integer',
|
||||||
'product_id' => 'integer',
|
'product_id' => 'integer',
|
||||||
'quantity' => 'decimal:2',
|
'quantity' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归属用户
|
* 归属门店
|
||||||
*/
|
*/
|
||||||
public function user(): BelongsTo
|
public function store(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(UserModel::class, 'user_id', 'id');
|
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
|||||||
use Modules\SystemTool\Models\SysFileModel;
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户等级模型(同一商品按客户等级定价)
|
* 客户等级模型(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100)
|
||||||
*/
|
*/
|
||||||
class CustomerLevelModel extends Model
|
class CustomerLevelModel extends Model
|
||||||
{
|
{
|
||||||
@@ -25,12 +25,14 @@ class CustomerLevelModel extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'percent',
|
||||||
'sort',
|
'sort',
|
||||||
'status',
|
'status',
|
||||||
'icon_id'
|
'icon_id'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'percent' => 'decimal:2',
|
||||||
'sort' => 'integer',
|
'sort' => 'integer',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
@@ -65,10 +67,17 @@ class CustomerLevelModel extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 该等级下的商品价格
|
* 按等级上浮比例计算售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||||
|
*
|
||||||
|
* 售价 = 成本价 × (100 + percent) / 100(四舍五入保留两位)。
|
||||||
|
*
|
||||||
|
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||||
|
* @param string|int|float $percent 价格上浮比例(30 = 上浮 30%)
|
||||||
|
* @return string 两位小数字符串,如 '13.05'
|
||||||
*/
|
*/
|
||||||
public function prices(): HasMany
|
public static function calcLevelPrice(string|int|float $costPrice, string|int|float $percent): string
|
||||||
{
|
{
|
||||||
return $this->hasMany(ProductPriceModel::class, 'level_id', 'id');
|
$multiplier = bcadd('100', (string) $percent, 4);
|
||||||
|
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序首页特价推荐商品
|
||||||
|
*/
|
||||||
|
class HomeSpecialModel extends Model
|
||||||
|
{
|
||||||
|
/** 状态:停用 */
|
||||||
|
public const int STATUS_DISABLED = 0;
|
||||||
|
/** 状态:正常 */
|
||||||
|
public const int STATUS_NORMAL = 1;
|
||||||
|
|
||||||
|
protected $table = 'mini_home_special';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'product_id',
|
||||||
|
'sort',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'product_id' => 'integer',
|
||||||
|
'sort' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联商品
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductModel::class, 'product_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知模型(小程序端消息:订单 / 价格变更 / 系统;user_id=0 为全员广播)
|
* 通知模型(小程序端消息:订单 / 价格变更 / 系统;store_id=0 为全员广播)
|
||||||
*/
|
*/
|
||||||
class NoticeModel extends Model
|
class NoticeModel extends Model
|
||||||
{
|
{
|
||||||
@@ -22,14 +22,14 @@ class NoticeModel extends Model
|
|||||||
/** 已读 */
|
/** 已读 */
|
||||||
public const READ = 1;
|
public const READ = 1;
|
||||||
|
|
||||||
/** 全员广播时的 user_id 约定值 */
|
/** 全员广播时的 store_id 约定值 */
|
||||||
public const BROADCAST_USER_ID = 0;
|
public const BROADCAST_STORE_ID = 0;
|
||||||
|
|
||||||
protected $table = 'notice';
|
protected $table = 'notice';
|
||||||
protected $primaryKey = 'id';
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'store_id',
|
||||||
'type',
|
'type',
|
||||||
'title',
|
'title',
|
||||||
'content',
|
'content',
|
||||||
@@ -41,15 +41,15 @@ class NoticeModel extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'data' => 'array',
|
'data' => 'array',
|
||||||
'is_read' => 'integer',
|
'is_read' => 'integer',
|
||||||
'user_id' => 'integer',
|
'store_id' => 'integer',
|
||||||
'read_at' => 'datetime',
|
'read_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接收用户(user_id=0 表示全员广播,无对应用户)
|
* 接收门店(store_id=0 表示全员广播,无对应门店)
|
||||||
*/
|
*/
|
||||||
public function user(): BelongsTo
|
public function store(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(UserModel::class, 'user_id', 'id');
|
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-14
@@ -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,17 +63,29 @@ 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';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'payment_no',
|
'payment_no',
|
||||||
'store_id',
|
'store_id',
|
||||||
'user_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,15 +94,29 @@ class PaymentModel extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'store_id' => 'integer',
|
'store_id' => 'integer',
|
||||||
'user_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(逗号分隔字符串 ↔ 数组)
|
||||||
*/
|
*/
|
||||||
@@ -124,14 +168,6 @@ class PaymentModel extends Model
|
|||||||
return $this->hasMany(BillModel::class, 'payment_id', 'id');
|
return $this->hasMany(BillModel::class, 'payment_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交人(小程序用户)
|
|
||||||
*/
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(UserModel::class, 'user_id', 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核人(后台系统用户)
|
* 审核人(后台系统用户)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -74,6 +74,36 @@ class ProductCategoryModel extends Model
|
|||||||
return static::buildTree($query->get($columns)->toArray());
|
return static::buildTree($query->get($columns)->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按树展示顺序(sort/id 深度优先)获取分类 ID 列表,供商品列表等按分类顺序排序
|
||||||
|
*
|
||||||
|
* @param bool $onlyEnabled 是否仅包含启用分类
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
public static function getTreeOrderedIds(bool $onlyEnabled = false): array
|
||||||
|
{
|
||||||
|
return static::flattenTreeIds(static::getTreeData(['id', 'parent_id'], $onlyEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度优先展开分类树为有序 ID 列表
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $tree
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private static function flattenTreeIds(array $tree): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($tree as $node) {
|
||||||
|
$ids[] = (int) $node['id'];
|
||||||
|
if (!empty($node['children'])) {
|
||||||
|
$ids = [...$ids, ...static::flattenTreeIds($node['children'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将扁平分类列表组装为树
|
* 将扁平分类列表组装为树
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Modules\SystemTool\Models\SysFileModel;
|
use Modules\SystemTool\Models\SysFileModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品档案模型(品名/规格包规/供应商/等级/多等级价格体系)
|
* 商品档案模型(品名/规格包规/供应商/成本价;售价按客户等级上浮比例换算)
|
||||||
*/
|
*/
|
||||||
class ProductModel extends Model
|
class ProductModel extends Model
|
||||||
{
|
{
|
||||||
@@ -28,9 +27,11 @@ class ProductModel extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'category_id',
|
'category_id',
|
||||||
'supplier_id',
|
'supplier_id',
|
||||||
|
'market',
|
||||||
'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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,12 +97,4 @@ class ProductModel extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 多等级价格(同一商品按客户等级定价)
|
|
||||||
*/
|
|
||||||
public function prices(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ProductPriceModel::class, 'product_id', 'id');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
|
||||||
*
|
|
||||||
* 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100)
|
|
||||||
*/
|
|
||||||
class ProductPriceModel extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
/** 计价类型:固定价 */
|
|
||||||
public const int PRICE_TYPE_FIXED = 0;
|
|
||||||
/** 计价类型:成本百分比(按成本价上浮 percent 百分点) */
|
|
||||||
public const int PRICE_TYPE_PERCENT = 1;
|
|
||||||
|
|
||||||
protected $table = 'product_price';
|
|
||||||
protected $primaryKey = 'id';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'product_id',
|
|
||||||
'level_id',
|
|
||||||
'price',
|
|
||||||
'price_type',
|
|
||||||
'percent',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'product_id' => 'integer',
|
|
||||||
'level_id' => 'integer',
|
|
||||||
'price' => 'decimal:2',
|
|
||||||
'price_type' => 'integer',
|
|
||||||
'percent' => 'decimal:2',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */
|
|
||||||
protected $appends = ['actual_price'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所属商品
|
|
||||||
*/
|
|
||||||
public function product(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所属客户等级
|
|
||||||
*/
|
|
||||||
public function level(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按商品 + 等级筛选价格
|
|
||||||
*/
|
|
||||||
public function scopeForProductLevel($query, int $productId, int $levelId)
|
|
||||||
{
|
|
||||||
return $query->where('product_id', $productId)->where('level_id', $levelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
|
||||||
*
|
|
||||||
* 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。
|
|
||||||
*
|
|
||||||
* @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串)
|
|
||||||
* @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%)
|
|
||||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
|
||||||
* @return string 两位小数字符串,如 '13.05'
|
|
||||||
*/
|
|
||||||
public static function calcActualPrice(
|
|
||||||
int $priceType,
|
|
||||||
string|int|float $price,
|
|
||||||
string|int|float $percent,
|
|
||||||
string|int|float $costPrice,
|
|
||||||
): string {
|
|
||||||
if ($priceType === self::PRICE_TYPE_PERCENT) {
|
|
||||||
$multiplier = bcadd('100', (string) $percent, 4);
|
|
||||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
|
||||||
}
|
|
||||||
// 固定价:归一化为两位小数字符串
|
|
||||||
return bcadd((string) $price, '0', 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 实际销售价访问器(供 toArray 输出 actual_price)
|
|
||||||
*
|
|
||||||
* 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。
|
|
||||||
* 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。
|
|
||||||
*/
|
|
||||||
protected function getActualPriceAttribute(): string
|
|
||||||
{
|
|
||||||
return self::calcActualPrice(
|
|
||||||
(int) $this->price_type,
|
|
||||||
(string) $this->price,
|
|
||||||
(string) $this->percent,
|
|
||||||
(string) ($this->product?->cost_price ?? 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Modules\SystemUser\Models\SysUserModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单单品对账标记模型(商品明细「已对账」勾选,入库持久化;存在记录=已标记)
|
||||||
|
*/
|
||||||
|
class PurchaseItemCheckModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'purchase_item_check';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'purchase_id',
|
||||||
|
'product_id',
|
||||||
|
'operator_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'purchase_id' => 'integer',
|
||||||
|
'product_id' => 'integer',
|
||||||
|
'operator_id' => 'integer',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联采购单
|
||||||
|
*/
|
||||||
|
public function purchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记人(后台系统用户)
|
||||||
|
*/
|
||||||
|
public function operator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ class PurchaseOrderModel extends Model
|
|||||||
'actual_amount' => 'decimal:2',
|
'actual_amount' => 'decimal:2',
|
||||||
'operator_id' => 'integer',
|
'operator_id' => 'integer',
|
||||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
// 列表 withSum 聚合属性(应付商品金额;无账单时保持 null)
|
||||||
|
'bill_product_amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,4 +71,12 @@ class PurchaseOrderModel extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
|
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本采购单生成的门店账单
|
||||||
|
*/
|
||||||
|
public function bills(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(BillModel::class, 'purchase_id', 'id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-12
@@ -3,17 +3,19 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店模型(小程序下单主体)
|
* 门店模型(小程序下单主体,即客户;门店即用户,账号密码登录)
|
||||||
*/
|
*/
|
||||||
class StoreModel extends Model
|
class StoreModel extends Authenticatable
|
||||||
{
|
{
|
||||||
use SoftDeletes, HasFactory;
|
use HasApiTokens, SoftDeletes, HasFactory, Notifiable;
|
||||||
|
|
||||||
/** 状态:停用 */
|
/** 状态:停用 */
|
||||||
public const STATUS_DISABLED = 0;
|
public const STATUS_DISABLED = 0;
|
||||||
@@ -26,20 +28,33 @@ class StoreModel extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'code',
|
'code',
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'avatar',
|
||||||
|
'last_login_at',
|
||||||
'level_id',
|
'level_id',
|
||||||
'contact',
|
'contact',
|
||||||
'phone',
|
'phone',
|
||||||
'address',
|
'address',
|
||||||
'payment_cycle_days',
|
'payment_cycle_days',
|
||||||
|
'openid',
|
||||||
|
'mp_openid',
|
||||||
'status',
|
'status',
|
||||||
'remark',
|
'remark',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'level_id' => 'integer',
|
'level_id' => 'integer',
|
||||||
'payment_cycle_days' => 'integer',
|
'payment_cycle_days' => 'integer',
|
||||||
'total_purchase_amount' => 'decimal:2',
|
'total_purchase_amount' => 'decimal:2',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
|
'last_login_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,14 +73,6 @@ class StoreModel extends Model
|
|||||||
return $this->hasMany(StoreOrderModel::class, 'store_id', 'id');
|
return $this->hasMany(StoreOrderModel::class, 'store_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定本门店的小程序用户
|
|
||||||
*/
|
|
||||||
public function users(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(UserModel::class, 'store_id', 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店账单(采购单完成后按门店生成)
|
* 门店账单(采购单完成后按门店生成)
|
||||||
*/
|
*/
|
||||||
@@ -73,4 +80,20 @@ class StoreModel extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BillModel::class, 'store_id', 'id');
|
return $this->hasMany(BillModel::class, 'store_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店购物车
|
||||||
|
*/
|
||||||
|
public function carts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CartModel::class, 'store_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店通知
|
||||||
|
*/
|
||||||
|
public function notices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NoticeModel::class, 'store_id', 'id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class StoreOrderItemModel extends Model
|
|||||||
'product_spec',
|
'product_spec',
|
||||||
'unit',
|
'unit',
|
||||||
'price',
|
'price',
|
||||||
|
'price_unit',
|
||||||
'image_ids',
|
'image_ids',
|
||||||
'content',
|
'content',
|
||||||
'shelf_life',
|
'shelf_life',
|
||||||
|
|||||||
@@ -43,12 +43,4 @@ class SupplierModel extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(ProductModel::class, 'supplier_id', 'id');
|
return $this->hasMany(ProductModel::class, 'supplier_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定本供应商的小程序用户
|
|
||||||
*/
|
|
||||||
public function users(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(UserModel::class, 'supplier_id', 'id');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* APP 用户模型(小程序端:门店 / 供应商用户)
|
|
||||||
*/
|
|
||||||
class UserModel extends Authenticatable
|
|
||||||
{
|
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
|
||||||
|
|
||||||
/** 状态:停用 */
|
|
||||||
public const int STATUS_DISABLED = 0;
|
|
||||||
/** 状态:正常 */
|
|
||||||
public const int STATUS_NORMAL = 1;
|
|
||||||
|
|
||||||
protected $table = 'user';
|
|
||||||
|
|
||||||
protected $primaryKey = 'id';
|
|
||||||
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'username',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
'nickname',
|
|
||||||
'openid',
|
|
||||||
'unionid',
|
|
||||||
'phone',
|
|
||||||
'avatar',
|
|
||||||
'store_id',
|
|
||||||
'status',
|
|
||||||
'last_login_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'email_verified_at' => 'datetime:Y-m-d H:i:s',
|
|
||||||
'last_login_at' => 'datetime:Y-m-d H:i:s',
|
|
||||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
|
||||||
'store_id' => 'integer',
|
|
||||||
'status' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关联门店
|
|
||||||
*/
|
|
||||||
public function store(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 用户通知
|
|
||||||
*/
|
|
||||||
public function notices(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(NoticeModel::class, 'user_id', 'id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Services\WechatService;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -18,9 +17,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
|
|
||||||
$this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class);
|
$this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class);
|
||||||
|
|
||||||
// 单例:测试通过 setHttpClient() 注入 Mock 后,控制器解析到同一实例
|
|
||||||
$this->app->singleton(WechatService::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ class BillDetailService
|
|||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => $weight,
|
'weight' => $weight,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
|
'spec' => $product->spec,
|
||||||
|
'price_unit' => $product->price_unit,
|
||||||
'image_ids' => (array) $first->image_ids,
|
'image_ids' => (array) $first->image_ids,
|
||||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||||
'product_sort' => (int) ($product->sort ?? 9999),
|
'product_sort' => (int) ($product->sort ?? 9999),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\ContainerReturnModel;
|
use App\Models\ContainerReturnModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\PurchaseOrderModel;
|
use App\Models\PurchaseOrderModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use App\Models\StoreOrderItemModel;
|
use App\Models\StoreOrderItemModel;
|
||||||
@@ -20,8 +21,10 @@ use Throwable;
|
|||||||
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
||||||
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
||||||
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
||||||
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
|
||||||
* 5. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
* 可正负:正数=加收,负数=售后减免)
|
||||||
|
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
||||||
|
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
||||||
*/
|
*/
|
||||||
readonly class BillGenerateService
|
readonly class BillGenerateService
|
||||||
{
|
{
|
||||||
@@ -31,7 +34,7 @@ readonly class BillGenerateService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PurchaseOrderModel $purchase 已完成采购单
|
* @param PurchaseOrderModel $purchase 已完成采购单
|
||||||
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量
|
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int, after_sale?: string|int|float, remark?: string}> $stores 按门店提交的配送费/周转筐/托盘数量与售后金额/备注
|
||||||
* @param int $operatorId 生成人(后台系统用户ID)
|
* @param int $operatorId 生成人(后台系统用户ID)
|
||||||
* @return BillModel[] 生成的账单列表
|
* @return BillModel[] 生成的账单列表
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
@@ -81,6 +84,14 @@ readonly class BillGenerateService
|
|||||||
$boxPrice = (string) site_config('services.box_amount', 0);
|
$boxPrice = (string) site_config('services.box_amount', 0);
|
||||||
$trayPrice = (string) site_config('services.tray_amount', 0);
|
$trayPrice = (string) site_config('services.tray_amount', 0);
|
||||||
$billDate = now()->toDateString();
|
$billDate = now()->toDateString();
|
||||||
|
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
|
||||||
|
$levelPercents = StoreModel::withTrashed()
|
||||||
|
->with('level:id,percent')
|
||||||
|
->whereIn('id', $ordersByStore->keys())
|
||||||
|
->get(['id', 'level_id'])
|
||||||
|
->mapWithKeys(static fn (StoreModel $store) => [
|
||||||
|
$store->id => (string) ($store->level?->percent ?? '0'),
|
||||||
|
]);
|
||||||
$bills = [];
|
$bills = [];
|
||||||
foreach ($ordersByStore as $storeId => $storeOrders) {
|
foreach ($ordersByStore as $storeId => $storeOrders) {
|
||||||
$row = $submitted[(int) $storeId];
|
$row = $submitted[(int) $storeId];
|
||||||
@@ -95,7 +106,12 @@ readonly class BillGenerateService
|
|||||||
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
|
// 售后金额按客户等级上浮比例折算(输入 15、上浮 1% → 实收 15.15;负数同比例放大)
|
||||||
|
$afterSale = CustomerLevelModel::calcLevelPrice(
|
||||||
|
(string) ($row['after_sale'] ?? '0'),
|
||||||
|
$levelPercents[(int) $storeId] ?? '0',
|
||||||
|
);
|
||||||
|
$totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
|
||||||
|
|
||||||
$bill = BillModel::create([
|
$bill = BillModel::create([
|
||||||
'bill_no' => $this->billNumberService->make('ZD'),
|
'bill_no' => $this->billNumberService->make('ZD'),
|
||||||
@@ -111,6 +127,8 @@ readonly class BillGenerateService
|
|||||||
'added_amount' => $addedAmount,
|
'added_amount' => $addedAmount,
|
||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'operator_id' => $operatorId,
|
'operator_id' => $operatorId,
|
||||||
|
'after_sale' => $afterSale,
|
||||||
|
'remark' => (string) ($row['remark'] ?? ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
|
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<?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)
|
||||||
|
* → 按当前渠道(site_config pay.online_channel)走旺铺统一下单或微信 JSAPI 下单
|
||||||
|
* → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||||
|
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
|
||||||
|
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
|
||||||
|
*
|
||||||
|
* 已创建的支付单按自身 pay_method 固定在原渠道查询/结账(渠道切换不影响进行中的支付单)。
|
||||||
|
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
|
||||||
|
*/
|
||||||
|
class OnlinePaymentService
|
||||||
|
{
|
||||||
|
/** 在线支付渠道:旺铺支付网关 */
|
||||||
|
public const string CHANNEL_WANGPU = 'wangpu';
|
||||||
|
/** 在线支付渠道:微信官方支付 */
|
||||||
|
public const string CHANNEL_WECHAT = 'wechat';
|
||||||
|
|
||||||
|
/** 支付场景:微信小程序(wx.login 换 openid,wx.requestPayment 调起) */
|
||||||
|
public const string SCENE_MINI = 'mini';
|
||||||
|
/** 支付场景:公众号 H5(网页授权换 openid,WeixinJSBridge 调起) */
|
||||||
|
public const string SCENE_MP = 'mp';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected BillNumberService $billNumber,
|
||||||
|
protected WangpuPayService $wangpu,
|
||||||
|
protected WechatPayService $wxpay,
|
||||||
|
protected WechatMiniService $wechat,
|
||||||
|
protected WechatMpService $wechatMp,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前在线支付渠道(site_config pay.online_channel 优先,回退 env PAY_ONLINE_CHANNEL,默认旺铺)
|
||||||
|
*/
|
||||||
|
public function channel(): string
|
||||||
|
{
|
||||||
|
$channel = (string) site_config('pay.online_channel', '');
|
||||||
|
if ($channel === '') {
|
||||||
|
$channel = (string) config('services.pay.online_channel', self::CHANNEL_WANGPU);
|
||||||
|
}
|
||||||
|
return $channel === self::CHANNEL_WECHAT ? self::CHANNEL_WECHAT : self::CHANNEL_WANGPU;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起在线支付
|
||||||
|
*
|
||||||
|
* @param array<int, int> $billIds 合并付款的账单ID
|
||||||
|
* @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分)
|
||||||
|
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
|
||||||
|
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
|
||||||
|
{
|
||||||
|
$channel = $this->channel();
|
||||||
|
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
|
||||||
|
|
||||||
|
// 换取付款人 openid 并绑定到门店(小程序/公众号分场景绑定,两者 openid 维度不同不可混用)
|
||||||
|
if ($scene === self::SCENE_MP) {
|
||||||
|
$openid = $this->wechatMp->code2openid($code);
|
||||||
|
if ((string) $store->mp_openid !== $openid) {
|
||||||
|
$store->mp_openid = $openid;
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$openid = $this->wechat->code2session($code);
|
||||||
|
if ((string) $store->openid !== $openid) {
|
||||||
|
$store->openid = $openid;
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
||||||
|
$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' => $payMethod,
|
||||||
|
'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 = $channel === self::CHANNEL_WECHAT
|
||||||
|
? $this->wxpay->createOrder(
|
||||||
|
$payment->payment_no,
|
||||||
|
(string) $payment->amount,
|
||||||
|
$openid,
|
||||||
|
'账单合并付款-' . $payment->payment_no,
|
||||||
|
$scene === self::SCENE_MP ? $this->wechatMp->appid() : '',
|
||||||
|
)
|
||||||
|
: $this->wangpu->createOrder([
|
||||||
|
'mer_order_id' => $payment->payment_no,
|
||||||
|
'order_amt' => (string) $payment->amount,
|
||||||
|
'open_id' => $openid,
|
||||||
|
'sub_appid' => $this->subAppid($scene),
|
||||||
|
'payway_code' => $this->wangpu->paywayCode($scene),
|
||||||
|
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||||
|
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 网关下单失败:整笔作废并释放账单,门店可重新发起
|
||||||
|
$this->discard($payment, $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel === self::CHANNEL_WANGPU) {
|
||||||
|
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||||
|
}
|
||||||
|
$payment->pay_params = $gatewayData;
|
||||||
|
$payment->save();
|
||||||
|
|
||||||
|
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
|
||||||
|
$wxjsapiData = $channel === self::CHANNEL_WANGPU
|
||||||
|
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
|
||||||
|
: $gatewayData;
|
||||||
|
|
||||||
|
return [$payment, $wxjsapiData];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount)
|
||||||
|
* @return bool 本次是否执行了结账(false = 重复通知)
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
public function settleByWechatNotify(array $params): bool
|
||||||
|
{
|
||||||
|
$outTradeNo = (string) ($params['out_trade_no'] ?? '');
|
||||||
|
$payment = PaymentModel::query()
|
||||||
|
->where('payment_no', $outTradeNo)
|
||||||
|
->where('pay_type', PaymentModel::TYPE_ONLINE)
|
||||||
|
->first();
|
||||||
|
if ($payment === null) {
|
||||||
|
throw new RepositoryException('支付记录不存在:' . $outTradeNo);
|
||||||
|
}
|
||||||
|
if (($params['trade_state'] ?? '') !== WechatPayService::TRADE_STATE_SUCCESS) {
|
||||||
|
throw new RepositoryException('订单未支付成功(trade_state=' . ($params['trade_state'] ?? '空') . ')');
|
||||||
|
}
|
||||||
|
// 商户号一致性校验,防串号
|
||||||
|
$mchId = (string) ($params['mchid'] ?? '');
|
||||||
|
if ($mchId !== '' && $mchId !== $this->wxpayMchId()) {
|
||||||
|
throw new RepositoryException('通知商户号与配置不一致');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->settle(
|
||||||
|
$payment,
|
||||||
|
(string) ($params['transaction_id'] ?? ''),
|
||||||
|
'',
|
||||||
|
(string) ($params['success_time'] ?? ''),
|
||||||
|
bcdiv((string) (int) ($params['amount']['total'] ?? 0), '100', 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动查询渠道订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
|
||||||
|
*
|
||||||
|
* 按支付单的 pay_method 固定在原渠道查询,渠道切换不影响进行中的支付单
|
||||||
|
*
|
||||||
|
* @return array{payment: PaymentModel, order_status: int} 最新支付单与渠道订单状态(1=已支付)
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
public function queryAndSettle(PaymentModel $payment): array
|
||||||
|
{
|
||||||
|
if ($payment->status === PaymentModel::STATUS_APPROVED) {
|
||||||
|
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payment->pay_method === PaymentModel::METHOD_WECHAT) {
|
||||||
|
return $this->queryWechatAndSettle($payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id);
|
||||||
|
$orderStatus = (int) ($data['order_status'] ?? -1);
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信渠道主动查询:trade_state=SUCCESS 则结账
|
||||||
|
*
|
||||||
|
* @return array{payment: PaymentModel, order_status: int}
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
protected function queryWechatAndSettle(PaymentModel $payment): array
|
||||||
|
{
|
||||||
|
$data = $this->wxpay->queryOrder($payment->payment_no);
|
||||||
|
$paid = ($data['trade_state'] ?? '') === WechatPayService::TRADE_STATE_SUCCESS;
|
||||||
|
|
||||||
|
if ($paid) {
|
||||||
|
$this->settle(
|
||||||
|
$payment,
|
||||||
|
(string) ($data['transaction_id'] ?? ''),
|
||||||
|
'',
|
||||||
|
(string) ($data['success_time'] ?? ''),
|
||||||
|
bcdiv((string) (int) ($data['amount']['total'] ?? 0), '100', 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['payment' => $payment->fresh(), 'order_status' => $paid ? 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
|
||||||
|
*
|
||||||
|
* @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[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $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;公众号 H5 场景取公众号 appid)
|
||||||
|
*/
|
||||||
|
protected function subAppid(string $scene = self::SCENE_MINI): string
|
||||||
|
{
|
||||||
|
if ($scene === self::SCENE_MP) {
|
||||||
|
return $this->wechatMp->appid();
|
||||||
|
}
|
||||||
|
$subAppid = (string) site_config('wangpu.sub_appid', '');
|
||||||
|
if ($subAppid === '') {
|
||||||
|
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
||||||
|
}
|
||||||
|
if ($subAppid === '') {
|
||||||
|
$subAppid = $this->wechat->appid();
|
||||||
|
}
|
||||||
|
return trim($subAppid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信渠道商户号(用于通知商户号一致性校验)
|
||||||
|
*/
|
||||||
|
protected function wxpayMchId(): string
|
||||||
|
{
|
||||||
|
$mchId = (string) site_config('wxpay.mch_id', '');
|
||||||
|
if ($mchId === '') {
|
||||||
|
$mchId = (string) config('services.wxpay.mch_id', '');
|
||||||
|
}
|
||||||
|
return trim($mchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,13 +63,18 @@ readonly class PurchaseGenerateService
|
|||||||
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
|
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
|
||||||
'0'
|
'0'
|
||||||
);
|
);
|
||||||
|
// 参考总重量 = Σ 明细参考重量(下单时按订货量 × 规格预填)
|
||||||
|
$totalWeight = $items->reduce(
|
||||||
|
static fn (string $carry, $item): string => bcadd($carry, (string) $item->weight, 3),
|
||||||
|
'0'
|
||||||
|
);
|
||||||
|
|
||||||
$purchase = PurchaseOrderModel::create([
|
$purchase = PurchaseOrderModel::create([
|
||||||
'purchase_no' => $this->billNumberService->make('PO'),
|
'purchase_no' => $this->billNumberService->make('PO'),
|
||||||
'purchase_date' => $date,
|
'purchase_date' => $date,
|
||||||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||||||
'total_quantity' => $totalQuantity,
|
'total_quantity' => $totalQuantity,
|
||||||
'total_weight' => 0,
|
'total_weight' => $totalWeight,
|
||||||
'estimate_amount' => $estimateAmount,
|
'estimate_amount' => $estimateAmount,
|
||||||
'actual_amount' => 0,
|
'actual_amount' => 0,
|
||||||
'operator_id' => $operatorId,
|
'operator_id' => $operatorId,
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
use App\Models\StoreOrderItemModel;
|
||||||
|
use App\Models\StoreOrderModel;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单明细共享服务:订单/采购单汇总重算 + 门店/供应商维度的商品行聚合
|
||||||
|
*
|
||||||
|
* 汇总口径(勿偏离):
|
||||||
|
* - 订单:total_quantity=Σ数量、total_weight=Σ称重、total_amount=Σ金额
|
||||||
|
* - 采购单:total_quantity=Σ数量、estimate_amount=Σ(数量×成本价)(预估成本)、total_weight=Σ称重
|
||||||
|
*/
|
||||||
|
class PurchaseItemService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 重算门店订单汇总(明细增删改后调用)
|
||||||
|
*/
|
||||||
|
public function recalcOrder(int $orderId): void
|
||||||
|
{
|
||||||
|
$order = StoreOrderModel::query()->find($orderId);
|
||||||
|
if ($order === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$order->total_quantity = (int) $order->items()->sum('quantity');
|
||||||
|
$order->total_weight = $order->items()->sum('weight');
|
||||||
|
$order->total_amount = $order->items()->sum('amount');
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重算采购单汇总(明细增删改后调用)
|
||||||
|
*/
|
||||||
|
public function recalcPurchase(int $purchaseId): void
|
||||||
|
{
|
||||||
|
$purchase = PurchaseOrderModel::query()->find($purchaseId);
|
||||||
|
if ($purchase === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$purchase->total_quantity = (int) StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchaseId)->sum('quantity');
|
||||||
|
$purchase->estimate_amount = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchaseId)->sum(DB::raw('quantity * cost_price'));
|
||||||
|
$purchase->total_weight = StoreOrderItemModel::query()
|
||||||
|
->where('purchase_id', $purchaseId)->sum('weight');
|
||||||
|
$purchase->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单内指定门店的采购汇总行(按商品聚合,单价=加权平均 Σ金额÷Σ数量,
|
||||||
|
* 排序:分类 sort → 商品 sort,与明细矩阵同序)
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function storeSummaryRows(int $purchaseId, int $storeId): array
|
||||||
|
{
|
||||||
|
$items = $this->purchaseItems($purchaseId)
|
||||||
|
->where('store_id', $storeId)
|
||||||
|
->sortBy('id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items->groupBy('product_id') as $productId => $group) {
|
||||||
|
$first = $group->first();
|
||||||
|
$quantity = 0;
|
||||||
|
$amount = '0';
|
||||||
|
$weight = '0';
|
||||||
|
foreach ($group as $item) {
|
||||||
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||||
|
$quantity += (int) $item->quantity;
|
||||||
|
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'product_id' => (int) $productId,
|
||||||
|
'product_name' => $first->product_name,
|
||||||
|
'product_spec' => $first->product_spec,
|
||||||
|
'unit' => $first->unit,
|
||||||
|
'market' => (string) data_get($first, 'market', ''),
|
||||||
|
// 加权平均单价(保证 单价×数量=预计金额)
|
||||||
|
'price' => $quantity > 0
|
||||||
|
? bcdiv($amount, (string) $quantity, 2)
|
||||||
|
: (string) $first->price,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'amount' => $amount,
|
||||||
|
'weight' => $weight,
|
||||||
|
'category_sort' => (int) data_get($first, 'category_sort', 9999),
|
||||||
|
'product_sort' => (int) data_get($first, 'product_sort', 9999),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sortRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单内指定供应商的商品聚合行(成本口径:金额=Σ数量×成本价,
|
||||||
|
* 排序:分类 sort → 商品 sort)
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function supplierRows(int $purchaseId, int $supplierId): array
|
||||||
|
{
|
||||||
|
$items = $this->purchaseItems($purchaseId)
|
||||||
|
->where('supplier_id', $supplierId)
|
||||||
|
->sortBy('id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items->groupBy('product_id') as $productId => $group) {
|
||||||
|
$first = $group->first();
|
||||||
|
$quantity = 0;
|
||||||
|
$weight = '0';
|
||||||
|
$amount = '0';
|
||||||
|
foreach ($group as $item) {
|
||||||
|
$quantity += (int) $item->quantity;
|
||||||
|
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||||
|
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'product_id' => (int) $productId,
|
||||||
|
'product_name' => $first->product_name,
|
||||||
|
'product_spec' => $first->product_spec,
|
||||||
|
'unit' => $first->unit,
|
||||||
|
'market' => (string) data_get($first, 'market', ''),
|
||||||
|
'cost_price' => (string) $first->cost_price,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'weight' => $weight,
|
||||||
|
'amount' => $amount,
|
||||||
|
'category_sort' => (int) data_get($first, 'category_sort', 9999),
|
||||||
|
'product_sort' => (int) data_get($first, 'product_sort', 9999),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键)
|
||||||
|
*
|
||||||
|
* @return Collection<int, StoreOrderItemModel>
|
||||||
|
*/
|
||||||
|
private function purchaseItems(int $purchaseId): Collection
|
||||||
|
{
|
||||||
|
$items = StoreOrderItemModel::query()
|
||||||
|
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||||
|
->where('store_order_item.purchase_id', $purchaseId)
|
||||||
|
->whereNull('store_order.deleted_at')
|
||||||
|
->select('store_order_item.*')
|
||||||
|
->get()
|
||||||
|
->makeVisible('cost_price');
|
||||||
|
|
||||||
|
// 排序键:商品分类 sort → 商品 sort;市场取商品档案(商品含软删除,保证历史单据可导出)
|
||||||
|
$products = ProductModel::withTrashed()
|
||||||
|
->with('category:id,sort')
|
||||||
|
->whereIn('id', $items->pluck('product_id')->unique())
|
||||||
|
->get(['id', 'category_id', 'sort', 'market'])
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$product = $products->get((int) $item->product_id);
|
||||||
|
$item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999));
|
||||||
|
$item->setAttribute('product_sort', (int) ($product?->sort ?? 9999));
|
||||||
|
$item->setAttribute('market', (string) ($product?->market ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序并剥离排序键
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortRows(array $rows): array
|
||||||
|
{
|
||||||
|
usort($rows, static fn (array $a, array $b): int =>
|
||||||
|
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||||
|
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||||
|
|
||||||
|
return array_map(static function (array $row): array {
|
||||||
|
unset($row['category_sort'], $row['product_sort']);
|
||||||
|
return $row;
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密)
|
||||||
|
*
|
||||||
|
* 通讯协议(与官方示例 demo/IndexController.php 一致):
|
||||||
|
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data;
|
||||||
|
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature;
|
||||||
|
* - 整体以 JSON 信封 POST:{serialNo, version, timestamp, data, signature, extras, organizNo};
|
||||||
|
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
|
||||||
|
*
|
||||||
|
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
|
||||||
|
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key,
|
||||||
|
* MD5(utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
|
||||||
|
*
|
||||||
|
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.php(env)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一下单(小程序 JSAPI)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
||||||
|
* @return array<string, mixed> 网关 data 解密后的业务报文(含 order_id/调起支付参数)
|
||||||
|
* @throws RepositoryException 网关应答失败
|
||||||
|
*/
|
||||||
|
public function createOrder(array $params): array
|
||||||
|
{
|
||||||
|
return $this->request('/industrial/payment/order', array_merge([
|
||||||
|
'mer_no' => $this->config('mer_no'),
|
||||||
|
'mer_code' => $this->config('mer_code'),
|
||||||
|
'term_code' => $this->config('term_code'),
|
||||||
|
'payway_code' => $this->config('payway_code'),
|
||||||
|
'organiz_no' => $this->config('organiz_no'),
|
||||||
|
], $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 = [
|
||||||
|
'organiz_no' => $this->config('organiz_no'),
|
||||||
|
'mer_order_id' => $merOrderId,
|
||||||
|
];
|
||||||
|
if ($orderId !== '') {
|
||||||
|
$params['order_id'] = $orderId;
|
||||||
|
}
|
||||||
|
return $this->request('/industrial/query/order', $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 通知表单报文(含 sign)
|
||||||
|
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
|
||||||
|
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
|
||||||
|
*/
|
||||||
|
public function verifyNotify(array $params): array
|
||||||
|
{
|
||||||
|
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
|
||||||
|
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
|
||||||
|
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
|
||||||
|
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
|
||||||
|
throw new RepositoryException('通知报文验签失败');
|
||||||
|
}
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 通知报文
|
||||||
|
* @throws RepositoryException 加签Key未配置
|
||||||
|
*/
|
||||||
|
protected function sign(array $params): string
|
||||||
|
{
|
||||||
|
unset($params['sign']);
|
||||||
|
$params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
ksort($params, SORT_STRING);
|
||||||
|
$str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params));
|
||||||
|
|
||||||
|
$signKey = $this->config('sign_key');
|
||||||
|
if ($signKey === '') {
|
||||||
|
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
|
||||||
|
}
|
||||||
|
return strtoupper(md5($str . '&key=' . $signKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
|
||||||
|
*/
|
||||||
|
public function notifyUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code)
|
||||||
|
*/
|
||||||
|
public function paywayCode(string $scene = 'mini'): string
|
||||||
|
{
|
||||||
|
if ($scene === 'mp') {
|
||||||
|
$mpPayway = $this->config('mp_payway_code');
|
||||||
|
if ($mpPayway !== '') {
|
||||||
|
return $mpPayway;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->config('payway_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
|
||||||
|
*
|
||||||
|
* @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('旺铺支付未配置网关地址,请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$envelope = $this->encryptEnvelope($params);
|
||||||
|
Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]);
|
||||||
|
|
||||||
|
$response = Http::timeout(15)->post($baseUrl . $path, $envelope);
|
||||||
|
Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
|
||||||
|
|
||||||
|
$result = $response->json();
|
||||||
|
if (! is_array($result)) {
|
||||||
|
Log::driver('pay')->error('旺铺网关应答异常', [
|
||||||
|
'path' => $path,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
||||||
|
}
|
||||||
|
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
|
||||||
|
Log::driver('pay')->warning('旺铺网关应答失败', [
|
||||||
|
'path' => $path,
|
||||||
|
'response' => $result,
|
||||||
|
]);
|
||||||
|
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->decryptEnvelope($result);
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]);
|
||||||
|
throw new RepositoryException('支付网关应答解密失败,请联系管理员');
|
||||||
|
}
|
||||||
|
Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params 业务参数
|
||||||
|
* @return array<string, string> 请求信封
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function encryptEnvelope(array $params): array
|
||||||
|
{
|
||||||
|
$key = Str::random(16); // AES 密钥(16 字节)
|
||||||
|
return [
|
||||||
|
'serialNo' => Str::random(32), // 请求流水号
|
||||||
|
'version' => '1.0',
|
||||||
|
'timestamp' => now()->format('YmdHis'),
|
||||||
|
'data' => urlencode($this->aesEncrypt($params, $key)),
|
||||||
|
'signature' => urlencode($this->rsaPublicEncrypt($key)),
|
||||||
|
'extras' => '',
|
||||||
|
'organizNo' => $this->config('organiz_no'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $envelope
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function decryptEnvelope(array $envelope): array
|
||||||
|
{
|
||||||
|
$data = (string) ($envelope['data'] ?? '');
|
||||||
|
$signature = (string) ($envelope['signature'] ?? '');
|
||||||
|
if ($data === '' || $signature === '') {
|
||||||
|
throw new RepositoryException('报文缺少 data/signature 字段');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature));
|
||||||
|
$decoded = json_decode($plain, true);
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
throw new RepositoryException('报文 JSON 解析失败');
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-ECB 加密(base64 输出,与官方示例算法一致)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function aesEncrypt(array $data, string $key): string
|
||||||
|
{
|
||||||
|
$cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RepositoryException('报文 AES 加密失败');
|
||||||
|
}
|
||||||
|
return base64_encode($cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-ECB 解密(base64 输入,与官方示例算法一致)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function aesDecrypt(string $cipherBase64, string $key): string
|
||||||
|
{
|
||||||
|
$plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
||||||
|
if (! is_string($plain)) {
|
||||||
|
throw new RepositoryException('报文 AES 解密失败');
|
||||||
|
}
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 平台公钥加密(base64 输出,用于加密 AES 密钥)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 加密失败
|
||||||
|
*/
|
||||||
|
protected function rsaPublicEncrypt(string $data): string
|
||||||
|
{
|
||||||
|
if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) {
|
||||||
|
throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置');
|
||||||
|
}
|
||||||
|
return base64_encode($encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 商户私钥解密(base64 输入,用于解出 AES 密钥)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 解密失败
|
||||||
|
*/
|
||||||
|
protected function rsaPrivateDecrypt(string $cipherBase64): string
|
||||||
|
{
|
||||||
|
$ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE'));
|
||||||
|
if (! $ok || ! is_string($decrypted)) {
|
||||||
|
throw new RepositoryException('商户私钥解密失败,请检查密钥配置');
|
||||||
|
}
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符)
|
||||||
|
*
|
||||||
|
* 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 未配置或密钥无法解析
|
||||||
|
*/
|
||||||
|
protected function pemKey(string $configKey, string $kind): string
|
||||||
|
{
|
||||||
|
$name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥';
|
||||||
|
$body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey));
|
||||||
|
if ($body === '') {
|
||||||
|
throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n"
|
||||||
|
. wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----";
|
||||||
|
|
||||||
|
if ($kind === 'PUBLIC') {
|
||||||
|
// 公钥兼容 SPKI(PUBLIC KEY)与 PKCS#1(RSA PUBLIC KEY)两种格式
|
||||||
|
foreach (['PUBLIC', 'RSA PUBLIC'] as $header) {
|
||||||
|
if (openssl_pkey_get_public($wrap($header)) !== false) {
|
||||||
|
return $wrap($header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false;
|
||||||
|
throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pem = $wrap('PRIVATE');
|
||||||
|
if (openssl_pkey_get_private($pem) === false) {
|
||||||
|
$isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false;
|
||||||
|
throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
|
||||||
|
}
|
||||||
|
return $pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取网关配置:后台站点配置优先,为空回退 config/services.php(env)
|
||||||
|
*/
|
||||||
|
protected function config(string $key): string
|
||||||
|
{
|
||||||
|
$value = site_config('wangpu.' . $key, '');
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = config('services.wangpu.' . $key, '');
|
||||||
|
}
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序服务(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 = $this->appid();
|
||||||
|
$secret = $this->config('mini_secret');
|
||||||
|
if ($appid === '' || $secret === '') {
|
||||||
|
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Http::timeout(10)->withOptions([
|
||||||
|
'verify' => false, // 禁用 SSL 证书验证
|
||||||
|
])->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序 AppID(在线支付下单 appid 兜底等场景复用)
|
||||||
|
*/
|
||||||
|
public function appid(): string
|
||||||
|
{
|
||||||
|
return $this->config('mini_appid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.php(env)
|
||||||
|
*/
|
||||||
|
protected function config(string $key): string
|
||||||
|
{
|
||||||
|
$value = site_config('wechat.' . $key, '');
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = match ($key) {
|
||||||
|
'mini_appid' => config('services.wechat.mini.appid', ''),
|
||||||
|
'mini_secret' => config('services.wechat.mini.secret', ''),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信公众号服务(网页授权 code 换 openid,公众号 JSAPI 支付前置)
|
||||||
|
*
|
||||||
|
* H5 页面在微信内置浏览器打开 → 网页授权(snsapi_base)回调带回 code
|
||||||
|
* → 本服务以 code 换付款人 openid(公众号维度,与小程序 openid 不同,不可混用)
|
||||||
|
*/
|
||||||
|
class WechatMpService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 网页授权 code 换 openid(sns/oauth2/access_token)
|
||||||
|
*
|
||||||
|
* @return string 用户公众号维度 openid
|
||||||
|
* @throws RepositoryException 未配置公众号或授权失败
|
||||||
|
*/
|
||||||
|
public function code2openid(string $code): string
|
||||||
|
{
|
||||||
|
$appid = $this->appid();
|
||||||
|
$secret = $this->config('mp_secret');
|
||||||
|
if ($appid === '' || $secret === '') {
|
||||||
|
throw new RepositoryException('微信公众号未配置 AppID/Secret,请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Http::timeout(10)->withOptions([
|
||||||
|
'verify' => false, // 禁用 SSL 证书验证
|
||||||
|
])->get('https://api.weixin.qq.com/sns/oauth2/access_token', [
|
||||||
|
'appid' => $appid,
|
||||||
|
'secret' => $secret,
|
||||||
|
'code' => $code,
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
])->json();
|
||||||
|
|
||||||
|
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
|
||||||
|
if ($openid === '') {
|
||||||
|
Log::warning('微信网页授权失败', ['response' => $result]);
|
||||||
|
throw new RepositoryException('微信网页授权失败:' . ($result['errmsg'] ?? '请重新授权'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公众号 AppID(公众号 JSAPI 下单 appid、H5 拼网页授权链接使用)
|
||||||
|
*/
|
||||||
|
public function appid(): string
|
||||||
|
{
|
||||||
|
return $this->config('mp_appid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.php(env)
|
||||||
|
*/
|
||||||
|
protected function config(string $key): string
|
||||||
|
{
|
||||||
|
$value = site_config('wechat.' . $key, '');
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = match ($key) {
|
||||||
|
'mp_appid' => config('services.wechat.mp.appid', ''),
|
||||||
|
'mp_secret' => config('services.wechat.mp.secret', ''),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use EasyWeChat\Pay\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信官方支付服务(APIv3 小程序 JSAPI,基于 EasyWeChat Pay)
|
||||||
|
*
|
||||||
|
* - 统一下单:POST /v3/pay/transactions/jsapi → prepay_id → 商户私钥签名生成 wx.requestPayment 调起参数
|
||||||
|
* - 交易查询:GET /v3/pay/transactions/out-trade-no/{商户订单号}
|
||||||
|
* - 支付通知:Wechatpay-Signature 验签(配置平台证书/公钥时)+ APIv3 密钥 AES-256-GCM 解密报文
|
||||||
|
*
|
||||||
|
* 配置优先级:后台「系统设置 → 微信官方支付」(site_config wxpay.*) > config/services.php(env)
|
||||||
|
*/
|
||||||
|
class WechatPayService
|
||||||
|
{
|
||||||
|
/** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */
|
||||||
|
public const string NOTIFY_ACK_OK = 'SUCCESS';
|
||||||
|
public const string NOTIFY_ACK_FAIL = 'FAIL';
|
||||||
|
|
||||||
|
/** 交易状态:支付成功(交易查询 / 支付通知的 trade_state) */
|
||||||
|
public const string TRADE_STATE_SUCCESS = 'SUCCESS';
|
||||||
|
|
||||||
|
protected ?Application $application = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一下单(JSAPI:小程序 / 公众号 H5 通用)
|
||||||
|
*
|
||||||
|
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||||
|
* @param string $amountYuan 金额(元,两位小数)
|
||||||
|
* @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid)
|
||||||
|
* @param string $description 订单标题
|
||||||
|
* @param string $appid 下单应用 appid(公众号 JSAPI 传公众号 appid;留空取配置的小程序 appid)
|
||||||
|
* @return array<string, mixed> 调起支付参数(appId/timeStamp/nonceStr/package/signType/paySign,
|
||||||
|
* 小程序喂 wx.requestPayment,公众号 H5 喂 WeixinJSBridge getBrandWCPayRequest,结构同构)
|
||||||
|
* @throws RepositoryException 未配置或下单失败
|
||||||
|
*/
|
||||||
|
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description, string $appid = ''): array
|
||||||
|
{
|
||||||
|
$appid = trim($appid) !== '' ? trim($appid) : $this->appid();
|
||||||
|
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
||||||
|
'appid' => $appid,
|
||||||
|
'mchid' => $this->config('mch_id'),
|
||||||
|
'description' => mb_substr($description, 0, 127),
|
||||||
|
'out_trade_no' => $paymentNo,
|
||||||
|
'notify_url' => $this->notifyUrl(),
|
||||||
|
'amount' => [
|
||||||
|
'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分
|
||||||
|
'currency' => 'CNY',
|
||||||
|
],
|
||||||
|
'payer' => ['openid' => $openid],
|
||||||
|
], '下单');
|
||||||
|
|
||||||
|
$prepayId = (string) ($result['prepay_id'] ?? '');
|
||||||
|
if ($prepayId === '') {
|
||||||
|
Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]);
|
||||||
|
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMiniAppConfig 即 buildBridgeConfig(小程序 wx.requestPayment 与公众号 WeixinJSBridge 参数同构)
|
||||||
|
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $appid);
|
||||||
|
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId, 'appid' => $appid]);
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易查询(按商户订单号)
|
||||||
|
*
|
||||||
|
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||||
|
* @return array<string, mixed> 微信交易报文(trade_state/transaction_id/success_time/amount.total 等)
|
||||||
|
* @throws RepositoryException 查询失败
|
||||||
|
*/
|
||||||
|
public function queryOrder(string $paymentNo): array
|
||||||
|
{
|
||||||
|
return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [
|
||||||
|
'mchid' => $this->config('mch_id'),
|
||||||
|
], '查询');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等)
|
||||||
|
* @throws RepositoryException 验签或解密失败
|
||||||
|
*/
|
||||||
|
public function decryptNotify(Request $request): array
|
||||||
|
{
|
||||||
|
Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]);
|
||||||
|
|
||||||
|
$app = $this->app();
|
||||||
|
$server = $app->getServer();
|
||||||
|
$server->setRequestFromSymfonyRequest($request);
|
||||||
|
|
||||||
|
// 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有)
|
||||||
|
if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') {
|
||||||
|
try {
|
||||||
|
$app->getValidator()->validate($server->getRequest());
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$message = $server->getRequestMessage();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $message->toArray();
|
||||||
|
Log::driver('pay')->info('微信支付通知解密', ['params' => $params]);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付结果后台通知地址(统一下单时上送的 notify_url)
|
||||||
|
*/
|
||||||
|
public function notifyUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入 EasyWeChat Application(测试 mock 用)
|
||||||
|
*/
|
||||||
|
public function setApplication(Application $application): static
|
||||||
|
{
|
||||||
|
$this->application = $application;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyWeChat Pay Application(按当前配置懒加载构建)
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 必填配置缺失或密钥无法解析
|
||||||
|
*/
|
||||||
|
public function app(): Application
|
||||||
|
{
|
||||||
|
if ($this->application !== null) {
|
||||||
|
return $this->application;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mchId = $this->config('mch_id');
|
||||||
|
if ($mchId === '') {
|
||||||
|
throw new RepositoryException('微信支付未配置商户号,请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformCerts = [];
|
||||||
|
$platformCert = $this->config('platform_cert');
|
||||||
|
$platformSerial = $this->config('platform_serial');
|
||||||
|
if ($platformCert !== '' && $platformSerial !== '') {
|
||||||
|
$platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->application = new Application([
|
||||||
|
'mch_id' => $mchId,
|
||||||
|
'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'),
|
||||||
|
'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'),
|
||||||
|
'secret_key' => $this->config('secret_key'),
|
||||||
|
'platform_certs' => $platformCerts,
|
||||||
|
]);
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RepositoryException('微信支付配置错误:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->application;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload POST 时为 JSON 报文,GET 时为 query 参数
|
||||||
|
* @return array<string, mixed> 应答报文
|
||||||
|
* @throws RepositoryException
|
||||||
|
*/
|
||||||
|
protected function call(string $method, string $uri, array $payload, string $action): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $method === 'GET'
|
||||||
|
? $this->app()->getClient()->get($uri, ['query' => $payload])
|
||||||
|
: $this->app()->getClient()->postJson($uri, $payload);
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]);
|
||||||
|
throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->getContent(false);
|
||||||
|
Log::driver('pay')->info('微信支付' . $action . '应答', [
|
||||||
|
'uri' => $uri,
|
||||||
|
'status' => $response->getStatusCode(),
|
||||||
|
'body' => $body,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = json_decode($body, true);
|
||||||
|
if ($response->isFailed()) {
|
||||||
|
$message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : '';
|
||||||
|
throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($result) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下单小程序 appid(默认取小程序自身 appid)
|
||||||
|
*/
|
||||||
|
protected function appid(): string
|
||||||
|
{
|
||||||
|
$appid = $this->config('appid');
|
||||||
|
if ($appid === '') {
|
||||||
|
$appid = (string) site_config('wechat.mini_appid', '');
|
||||||
|
}
|
||||||
|
if ($appid === '') {
|
||||||
|
$appid = (string) config('services.wechat.mini.appid', '');
|
||||||
|
}
|
||||||
|
return $appid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径
|
||||||
|
*
|
||||||
|
* @throws RepositoryException 未配置
|
||||||
|
*/
|
||||||
|
protected function pemContent(string $value, string $kind): string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
$name = $kind === 'PRIVATE' ? '商户API私钥' : '证书';
|
||||||
|
throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员');
|
||||||
|
}
|
||||||
|
if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_file($value)) {
|
||||||
|
return 'file://' . $value;
|
||||||
|
}
|
||||||
|
// base64 单行内容包装为 PEM
|
||||||
|
$header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE';
|
||||||
|
return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取支付配置:后台站点配置优先,为空回退 config/services.php(env)
|
||||||
|
*/
|
||||||
|
protected function config(string $key): string
|
||||||
|
{
|
||||||
|
$value = site_config('wxpay.' . $key, '');
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = config('services.wxpay.' . $key, '');
|
||||||
|
}
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
|
||||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
|
||||||
use EasyWeChat\MiniApp\Application;
|
|
||||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信小程序服务(基于 EasyWeChat 6.x)
|
|
||||||
*
|
|
||||||
* 封装 code2Session / 手机号解密;配置读取 site_config('wechatMini')
|
|
||||||
* (后台「小程序设置」面板维护,存 sys_site_config 表)。
|
|
||||||
*
|
|
||||||
* 测试策略:通过 setHttpClient() 注入 Symfony MockHttpClient 拦截微信 HTTP 调用。
|
|
||||||
*/
|
|
||||||
class WechatService
|
|
||||||
{
|
|
||||||
private ?Application $app = null;
|
|
||||||
|
|
||||||
private ?HttpClientInterface $httpClient = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注入自定义 HttpClient(测试注入 MockHttpClient;注入后强制重建 Application)
|
|
||||||
*/
|
|
||||||
public function setHttpClient(HttpClientInterface $httpClient): void
|
|
||||||
{
|
|
||||||
$this->httpClient = $httpClient;
|
|
||||||
$this->app = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* code2Session:小程序 wx.login 的 code 换取 openid / session_key
|
|
||||||
*
|
|
||||||
* @param string $code wx.login 返回的临时登录凭证
|
|
||||||
* @return array{openid: string, session_key: string, unionid?: string}
|
|
||||||
*/
|
|
||||||
public function code2Session(string $code): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
/** @var array{openid: string, session_key: string, unionid?: string} $session */
|
|
||||||
$session = $this->app()->getUtils()->codeToSession($code);
|
|
||||||
} catch (HttpException|TransportExceptionInterface $e) {
|
|
||||||
throw new RepositoryException('微信登录失败:' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取手机号:wx.getPhoneNumber 的 phoneCode 换取手机号
|
|
||||||
*
|
|
||||||
* @param string $phoneCode 手机号授权事件返回的动态令牌
|
|
||||||
* @return string 用户手机号
|
|
||||||
*/
|
|
||||||
public function getPhone(string $phoneCode): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$result = $this->app()->getUtils()->getPhoneNumber($phoneCode);
|
|
||||||
} catch (HttpException|TransportExceptionInterface $e) {
|
|
||||||
throw new RepositoryException('获取手机号失败:' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$phone = (string) ($result['phone_info']['phoneNumber']
|
|
||||||
?? $result['phone_info']['purePhoneNumber']
|
|
||||||
?? '');
|
|
||||||
if ($phone === '') {
|
|
||||||
throw new RepositoryException('获取手机号失败:微信未返回有效手机号');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $phone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EasyWeChat 小程序应用实例(懒构建单例)
|
|
||||||
*/
|
|
||||||
protected function app(): Application
|
|
||||||
{
|
|
||||||
if ($this->app === null) {
|
|
||||||
$config = (array) site_config('wechatMini', []);
|
|
||||||
if (empty($config['appid']) || empty($config['secret'])) {
|
|
||||||
throw new RepositoryException('微信小程序尚未配置(WECHAT_MINI_APPID / WECHAT_MINI_SECRET)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->app = new Application([
|
|
||||||
'app_id' => (string) $config['appid'],
|
|
||||||
'secret' => (string) $config['secret'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($this->httpClient !== null) {
|
|
||||||
$this->app->setHttpClient($this->httpClient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->app;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参考重量估算:订货量 × 单品规格折算(斤),仅作参考,实际称重以人工录入为准
|
||||||
|
*/
|
||||||
|
class WeightEstimator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 估算参考重量(斤,3 位小数)
|
||||||
|
*
|
||||||
|
* 计价单位本身是重量单位时订货量即重量;否则从规格(如「10斤/箱」「500g/袋」)
|
||||||
|
* 解析每件重量再乘订货量;规格无法解析时计 0
|
||||||
|
*/
|
||||||
|
public static function estimate(string $spec, string $unit, string $quantity): string
|
||||||
|
{
|
||||||
|
return bcmul($quantity === '' ? '0' : $quantity, self::perUnitJin($spec, $unit), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个计价单位折算重量(斤)
|
||||||
|
*/
|
||||||
|
private static function perUnitJin(string $spec, string $unit): string
|
||||||
|
{
|
||||||
|
// 按重量计价的单位:订货量本身就是重量
|
||||||
|
$unitWeight = self::toJin('1', $unit);
|
||||||
|
if ($unitWeight !== null) {
|
||||||
|
return $unitWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从规格解析每件重量(如「10斤/箱」「500g/袋」「1.5kg/箱」)
|
||||||
|
if (preg_match('/(\d+(?:\.\d+)?)\s*(公斤|千克|kg|克|斤|g)/iu', $spec, $matches) === 1) {
|
||||||
|
$parsed = self::toJin($matches[1], $matches[2]);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规格中的裸数字按斤计(如「10/箱」);无法解析计 0
|
||||||
|
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1) {
|
||||||
|
return $matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数值按单位折算为斤;非重量单位返回 null(1斤=500g,1公斤/千克/kg=2斤)
|
||||||
|
*/
|
||||||
|
private static function toJin(string $value, string $unit): ?string
|
||||||
|
{
|
||||||
|
return match (mb_strtolower(trim($unit))) {
|
||||||
|
'斤' => $value,
|
||||||
|
'公斤', '千克', 'kg' => bcmul($value, '2', 3),
|
||||||
|
'克', 'g' => bcdiv($value, '500', 3),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -17,7 +17,8 @@
|
|||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"predis/predis": "2.0",
|
"predis/predis": "2.0",
|
||||||
"w7corp/easywechat": "^6.19"
|
"w7corp/easywechat": "^6.19",
|
||||||
|
"ext-openssl": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.0",
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ return [
|
|||||||
],
|
],
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => \App\Models\UserModel::class
|
'model' => \App\Models\StoreModel::class
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ return [
|
|||||||
'level' => 'debug',
|
'level' => 'debug',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'pay' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/pay.log'),
|
||||||
|
'level' => 'debug',
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -45,11 +45,58 @@ return [
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
||||||
|
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
|
||||||
|
* 公众号(mp):网页授权换 openid、公众号 JSAPI 支付使用,后台 site_config wechat.mp_appid / wechat.mp_secret 优先
|
||||||
*/
|
*/
|
||||||
'wechat' => [
|
'wechat' => [
|
||||||
'mini' => [
|
'mini' => [
|
||||||
'appid' => env('WECHAT_MINI_APPID', ''),
|
'appid' => env('WECHAT_MINI_APPID', ''),
|
||||||
'secret' => env('WECHAT_MINI_SECRET', ''),
|
'secret' => env('WECHAT_MINI_SECRET', ''),
|
||||||
],
|
],
|
||||||
|
'mp' => [
|
||||||
|
'appid' => env('WECHAT_MP_APPID', ''),
|
||||||
|
'secret' => env('WECHAT_MP_SECRET', ''),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 在线支付渠道开关(二选一):wangpu=旺铺支付网关 / wechat=微信官方支付
|
||||||
|
* 优先读取后台「系统设置 → 支付配置」(site_config pay.online_channel),为空时回退到这里的 env 配置
|
||||||
|
*/
|
||||||
|
'pay' => [
|
||||||
|
'online_channel' => env('PAY_ONLINE_CHANNEL', 'wangpu'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
|
||||||
|
* 优先读取后台「系统设置 → 旺铺支付」(site_config 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', ''), // 终端号(进件入网后返回)
|
||||||
|
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
|
||||||
|
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
|
||||||
|
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
||||||
|
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(小程序主扫必填)
|
||||||
|
'mp_payway_code' => env('WANGPU_MP_PAYWAY_CODE', ''), // 公众号场景支付方式代码(留空回退 payway_code)
|
||||||
|
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 支付/退款后台通知 MD5 专用加签Key(旺铺分配)
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 微信官方支付(APIv3 小程序 JSAPI,基于 EasyWeChat Pay)
|
||||||
|
* 优先读取后台「系统设置 → 微信官方支付」(site_config wxpay.*),为空时回退到这里的 env 配置
|
||||||
|
* 密钥/证书支持:完整 PEM 文本、base64 单行、或以 file:// 开头的文件路径
|
||||||
|
*/
|
||||||
|
'wxpay' => [
|
||||||
|
'mch_id' => env('WXPAY_MCH_ID', ''), // 微信支付商户号
|
||||||
|
'appid' => env('WXPAY_APPID', ''), // 下单小程序 appid(留空取小程序自身 appid)
|
||||||
|
'private_key' => env('WXPAY_PRIVATE_KEY', ''), // 商户 API 私钥(apiclient_key.pem)
|
||||||
|
'certificate' => env('WXPAY_CERTIFICATE', ''), // 商户 API 证书(apiclient_cert.pem,请求签名序列号取自证书)
|
||||||
|
'secret_key' => env('WXPAY_SECRET_KEY', ''), // APIv3 密钥(回调报文 AES-256-GCM 解密)
|
||||||
|
'platform_cert' => env('WXPAY_PLATFORM_CERT', ''), // 微信支付平台证书/微信支付公钥(回调验签,可空)
|
||||||
|
'platform_serial' => env('WXPAY_PLATFORM_SERIAL', ''), // 平台证书序列号/微信支付公钥ID(配合 platform_cert)
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ProductModelFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'category_id' => 0,
|
'category_id' => 0,
|
||||||
'supplier_id' => 0,
|
'supplier_id' => 0,
|
||||||
|
'market' => '',
|
||||||
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
||||||
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
||||||
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\ProductPriceModel;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品等级价格工厂(product_id / level_id 需调用方指定;无需 Faker)
|
|
||||||
*
|
|
||||||
* @extends Factory<ProductPriceModel>
|
|
||||||
*/
|
|
||||||
class ProductPriceModelFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = ProductPriceModel::class;
|
|
||||||
|
|
||||||
private static int $sequence = 0;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
$seq = ++self::$sequence;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'product_id' => 0,
|
|
||||||
'level_id' => 0,
|
|
||||||
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
|
||||||
'price_type' => ProductPriceModel::PRICE_TYPE_FIXED,
|
|
||||||
'percent' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,10 @@ class StoreModelFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'name' => '测试门店' . $seq,
|
'name' => '测试门店' . $seq,
|
||||||
'code' => 'S' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT),
|
'code' => 'S' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT),
|
||||||
|
'username' => 'store' . $seq,
|
||||||
|
'password' => password_hash('123456', PASSWORD_DEFAULT),
|
||||||
|
'avatar' => '',
|
||||||
|
'last_login_at' => null,
|
||||||
'level_id' => 0,
|
'level_id' => 0,
|
||||||
'contact' => '联系人' . $seq,
|
'contact' => '联系人' . $seq,
|
||||||
'phone' => '138' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
'phone' => '138' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
||||||
@@ -41,6 +45,14 @@ class StoreModelFactory extends Factory
|
|||||||
return $this->state(fn () => ['status' => StoreModel::STATUS_DISABLED]);
|
return $this->state(fn () => ['status' => StoreModel::STATUS_DISABLED]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定登录密码(明文,入库自动哈希)
|
||||||
|
*/
|
||||||
|
public function withPassword(string $password): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['password' => password_hash($password, PASSWORD_DEFAULT)]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定回款周期(天)
|
* 指定回款周期(天)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\UserModel;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 小程序用户工厂(微信登录自动生成,供测试使用;无需 Faker)
|
|
||||||
*
|
|
||||||
* @extends Factory<UserModel>
|
|
||||||
*/
|
|
||||||
class UserModelFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = UserModel::class;
|
|
||||||
|
|
||||||
private static int $sequence = 0;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
$seq = ++self::$sequence;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'username' => null,
|
|
||||||
'password' => null,
|
|
||||||
'nickname' => '微信用户' . $seq,
|
|
||||||
'email' => '',
|
|
||||||
'openid' => 'openid_' . str_pad((string) $seq, 16, '0', STR_PAD_LEFT),
|
|
||||||
'unionid' => '',
|
|
||||||
'phone' => '',
|
|
||||||
'avatar' => '',
|
|
||||||
'store_id' => 0,
|
|
||||||
'status' => UserModel::STATUS_NORMAL,
|
|
||||||
'last_login_at' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已绑定门店的门店用户
|
|
||||||
*/
|
|
||||||
public function forStore(int $storeId): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'store_id' => $storeId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停用账号
|
|
||||||
*/
|
|
||||||
public function disabled(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => ['status' => UserModel::STATUS_DISABLED]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ return new class extends Migration
|
|||||||
$table->string('key', 50)->comment('设置项标示');
|
$table->string('key', 50)->comment('设置项标示');
|
||||||
$table->string('title', 50)->comment('设置标题');
|
$table->string('title', 50)->comment('设置标题');
|
||||||
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
|
$table->string('describe', 500)->nullable()->default('')->comment('设置项描述');
|
||||||
$table->string('values', 255)->nullable()->default('')->comment('设置值');
|
$table->string('values', 2000)->nullable()->default('')->comment('设置值');
|
||||||
$table->string('type', 50)->comment('设置类型');
|
$table->string('type', 50)->comment('设置类型');
|
||||||
$table->string('options', 500)->nullable()->comment('options配置');
|
$table->string('options', 500)->nullable()->comment('options配置');
|
||||||
$table->string('props', 500)->nullable()->comment('props配置');
|
$table->string('props', 500)->nullable()->comment('props配置');
|
||||||
|
|||||||
@@ -12,36 +12,13 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('user')) {
|
|
||||||
Schema::create('user', function (Blueprint $table) {
|
|
||||||
$table->increments('id')->comment('用户ID');
|
|
||||||
$table->string('username', 20)->nullable()->unique()->comment('用户名');
|
|
||||||
$table->string('password', 100)->nullable()->comment('密码');
|
|
||||||
$table->string('nickname', 20)->default('')->comment('昵称');
|
|
||||||
$table->string('email', 50)->default('')->comment('邮箱');
|
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
|
||||||
// 小程序用户扩展字段
|
|
||||||
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID');
|
|
||||||
$table->string('unionid', 64)->default('')->comment('微信UnionID');
|
|
||||||
$table->string('phone', 20)->default('')->comment('手机号');
|
|
||||||
$table->string('avatar', 255)->default('')->comment('头像');
|
|
||||||
$table->integer('store_id')->default(0)->comment('关联门店ID');
|
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
|
||||||
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
|
|
||||||
$table->rememberToken();
|
|
||||||
$table->timestamps();
|
|
||||||
$table->index('store_id', 'user_type_index');
|
|
||||||
$table->index('openid', 'user_openid_index');
|
|
||||||
$table->comment('APP用户表(含小程序用户)');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 客户等级表(价格体系按等级定价)
|
// 客户等级表(价格体系按等级定价)
|
||||||
if (! Schema::hasTable('customer_level')) {
|
if (! Schema::hasTable('customer_level')) {
|
||||||
Schema::create('customer_level', function (Blueprint $table) {
|
Schema::create('customer_level', function (Blueprint $table) {
|
||||||
$table->increments('id')->comment('等级ID');
|
$table->increments('id')->comment('等级ID');
|
||||||
$table->integer('icon_id')->nullable()->comment('等级图标ID');
|
$table->integer('icon_id')->nullable()->comment('等级图标ID');
|
||||||
$table->string('name', 50)->comment('等级名称(如:一级客户、二级客户)');
|
$table->string('name', 50)->comment('等级名称(如:一级客户、二级客户)');
|
||||||
|
$table->decimal('percent', 5, 2)->default(0)->comment('价格上浮比例(%,如 30 = 成本价上浮30%)');
|
||||||
$table->integer('sort')->default(0)->comment('排序');
|
$table->integer('sort')->default(0)->comment('排序');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
@@ -55,18 +32,26 @@ return new class extends Migration
|
|||||||
$table->increments('id')->comment('门店ID');
|
$table->increments('id')->comment('门店ID');
|
||||||
$table->string('name', 100)->comment('门店名称');
|
$table->string('name', 100)->comment('门店名称');
|
||||||
$table->string('code', 50)->unique()->comment('门店编码');
|
$table->string('code', 50)->unique()->comment('门店编码');
|
||||||
|
// 登录账号字段(小程序端 账号 + 密码 登录)
|
||||||
|
$table->string('username', 20)->unique()->comment('登录账号');
|
||||||
|
$table->string('password', 100)->comment('登录密码');
|
||||||
|
$table->string('avatar', 255)->default('')->comment('头像');
|
||||||
|
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
|
||||||
$table->integer('level_id')->default(0)->comment('客户等级ID(决定商品价格)');
|
$table->integer('level_id')->default(0)->comment('客户等级ID(决定商品价格)');
|
||||||
$table->string('contact', 50)->default('')->comment('联系人');
|
$table->string('contact', 50)->default('')->comment('联系人');
|
||||||
$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->string('mp_openid', 64)->default('')->comment('微信公众号 openid(公众号 JSAPI 支付付款人标识,网页授权换取后绑定)');
|
||||||
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||||
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
$table->index(['level_id', 'status'], 'store_level_status_index');
|
$table->index(['level_id', 'status'], 'store_level_status_index');
|
||||||
$table->comment('门店表');
|
$table->comment('门店表(门店即用户,账号密码登录)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +76,7 @@ return new class extends Migration
|
|||||||
if (! Schema::hasTable('notice')) {
|
if (! Schema::hasTable('notice')) {
|
||||||
Schema::create('notice', function (Blueprint $table) {
|
Schema::create('notice', function (Blueprint $table) {
|
||||||
$table->increments('id')->comment('通知ID');
|
$table->increments('id')->comment('通知ID');
|
||||||
$table->integer('user_id')->default(0)->comment('接收用户ID(user表,0为全员广播)');
|
$table->integer('store_id')->default(0)->comment('接收门店ID(store表,0为全员广播)');
|
||||||
$table->string('type', 20)->default('system')->comment('通知类型(order订单 price价格 system系统)');
|
$table->string('type', 20)->default('system')->comment('通知类型(order订单 price价格 system系统)');
|
||||||
$table->string('title', 100)->comment('标题');
|
$table->string('title', 100)->comment('标题');
|
||||||
$table->string('content', 500)->default('')->comment('内容');
|
$table->string('content', 500)->default('')->comment('内容');
|
||||||
@@ -99,7 +84,7 @@ return new class extends Migration
|
|||||||
$table->integer('is_read')->default(0)->comment('是否已读(1已读 0未读)');
|
$table->integer('is_read')->default(0)->comment('是否已读(1已读 0未读)');
|
||||||
$table->timestamp('read_at')->nullable()->comment('阅读时间');
|
$table->timestamp('read_at')->nullable()->comment('阅读时间');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->index(['user_id', 'is_read'], 'notice_user_read_index');
|
$table->index(['store_id', 'is_read'], 'notice_store_read_index');
|
||||||
$table->comment('消息通知表');
|
$table->comment('消息通知表');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -110,7 +95,6 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::dropIfExists('user');
|
|
||||||
Schema::dropIfExists('customer_level');
|
Schema::dropIfExists('customer_level');
|
||||||
Schema::dropIfExists('store');
|
Schema::dropIfExists('store');
|
||||||
Schema::dropIfExists('supplier');
|
Schema::dropIfExists('supplier');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run the migrations.
|
* Run the migrations.
|
||||||
* 商品中心(A1-A3):商品分类、商品档案、客户等级价格体系
|
* 商品中心(A1-A3):商品分类、商品档案(等级价格 = 成本价 × customer_level.percent 上浮)
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
@@ -32,17 +32,19 @@ return new class extends Migration
|
|||||||
$table->increments('id')->comment('商品ID');
|
$table->increments('id')->comment('商品ID');
|
||||||
$table->integer('category_id')->default(0)->comment('商品分类ID');
|
$table->integer('category_id')->default(0)->comment('商品分类ID');
|
||||||
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
|
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
|
||||||
|
$table->string('market', 50)->default('')->nullable()->comment('市场(如:新发地、岳各庄)');
|
||||||
$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('排序');
|
||||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
$table->integer('shelf_life')->default(0)->nullable()->comment('保质期');
|
||||||
$table->integer('stock')->default(0)->comment('库存');
|
$table->integer('stock')->default(0)->comment('库存');
|
||||||
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
|
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
|
||||||
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)');
|
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)');
|
||||||
$table->string('remark', 255)->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
$table->index(['category_id', 'status'], 'product_category_status_index');
|
$table->index(['category_id', 'status'], 'product_category_status_index');
|
||||||
@@ -50,21 +52,6 @@ return new class extends Migration
|
|||||||
$table->comment('商品表');
|
$table->comment('商品表');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 商品等级价格表
|
|
||||||
if (! Schema::hasTable('product_price')) {
|
|
||||||
Schema::create('product_price', function (Blueprint $table) {
|
|
||||||
$table->increments('id')->comment('价格ID');
|
|
||||||
$table->integer('product_id')->comment('商品ID');
|
|
||||||
$table->integer('level_id')->comment('客户等级ID');
|
|
||||||
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价(固定价=实际单价;百分比=等价固定价)');
|
|
||||||
$table->unsignedTinyInteger('price_type')->default(0)->comment('计价类型(0固定价 1成本百分比)');
|
|
||||||
$table->decimal('percent', 5, 2)->default(0)->comment('成本上浮百分点(如 30 = 上浮30%,仅 price_type=1 生效)');
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
|
|
||||||
$table->comment('商品等级价格表');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +61,5 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
Schema::dropIfExists('product_category');
|
Schema::dropIfExists('product_category');
|
||||||
Schema::dropIfExists('product');
|
Schema::dropIfExists('product');
|
||||||
Schema::dropIfExists('product_price');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ return new class extends Migration
|
|||||||
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
|
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
|
||||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
|
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
|
||||||
$table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)');
|
$table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)');
|
||||||
$table->string('remark', 255)->default('')->comment('订单备注');
|
$table->string('remark', 255)->default('')->nullable()->comment('订单备注');
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
|
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
|
||||||
@@ -49,6 +49,7 @@ return new class extends Migration
|
|||||||
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
||||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||||
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
||||||
|
$table->string('price_unit', 20)->default('')->comment('单价单位');
|
||||||
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
||||||
$table->text('content')->comment('商品图文详情');
|
$table->text('content')->comment('商品图文详情');
|
||||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ return new class extends Migration
|
|||||||
$table->decimal('estimate_amount', 10, 2)->default(0)->comment('预估金额(按订货汇总)');
|
$table->decimal('estimate_amount', 10, 2)->default(0)->comment('预估金额(按订货汇总)');
|
||||||
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额');
|
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额');
|
||||||
$table->integer('operator_id')->default(0)->comment('采购员(系统用户ID)');
|
$table->integer('operator_id')->default(0)->comment('采购员(系统用户ID)');
|
||||||
$table->string('remark', 255)->default('')->comment('备注');
|
$table->string('remark', 255)->default('')->nullable()->comment('备注');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->index(['purchase_date', 'status'], 'purchase_order_date_status_index');
|
$table->index(['purchase_date', 'status'], 'purchase_order_date_status_index');
|
||||||
$table->comment('采购单表');
|
$table->comment('采购单表');
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run the migrations.
|
* Run the migrations.
|
||||||
* 小程序购物车(门店订货车):按用户归属,同商品唯一行、加购合并累加
|
* 小程序购物车(门店订货车):按门店归属,同商品唯一行、加购合并累加
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('cart')) {
|
if (! Schema::hasTable('cart')) {
|
||||||
Schema::create('cart', function (Blueprint $table) {
|
Schema::create('cart', function (Blueprint $table) {
|
||||||
$table->increments('id')->comment('购物车项ID');
|
$table->increments('id')->comment('购物车项ID');
|
||||||
$table->integer('user_id')->comment('用户ID(购物车归属者)');
|
$table->integer('store_id')->comment('门店ID(购物车归属者)');
|
||||||
$table->integer('product_id')->comment('商品ID');
|
$table->integer('product_id')->comment('商品ID');
|
||||||
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
|
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['user_id', 'product_id'], 'cart_user_product_unique');
|
$table->unique(['store_id', 'product_id'], 'cart_store_product_unique');
|
||||||
$table->index(['user_id', 'created_at'], 'cart_user_created_index');
|
$table->index(['store_id', 'created_at'], 'cart_store_created_index');
|
||||||
$table->comment('小程序购物车表(门店订货车)');
|
$table->comment('小程序购物车表(门店订货车)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,15 @@ return new class extends Migration
|
|||||||
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
|
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
|
||||||
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
|
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
|
||||||
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
|
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
|
||||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额');
|
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额');
|
||||||
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
|
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
|
||||||
$table->integer('payment_id')->default(0)->comment('关联支付记录ID(0=未发起支付)');
|
$table->integer('payment_id')->default(0)->comment('关联支付记录ID(0=未发起支付)');
|
||||||
$table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)');
|
$table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)');
|
||||||
$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)->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注(生成账单时按门店填写)');
|
||||||
|
$table->decimal('after_sale', 10, 2)->default(0)->comment('售后金额(生成账单时按门店填写,可正负,计入总金额)');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
|
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
|
||||||
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
|
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
|
||||||
|
|||||||
@@ -15,20 +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->integer('user_id')->default(0)->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('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* 小程序首页特价推荐:后台批量选择商品标记为推荐,小程序端按推荐排序展示
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('mini_home_special')) {
|
||||||
|
Schema::create('mini_home_special', function (Blueprint $table) {
|
||||||
|
$table->increments('id')->comment('推荐ID');
|
||||||
|
$table->integer('product_id')->comment('商品ID');
|
||||||
|
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||||
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique('product_id', 'uk_mini_home_special_product');
|
||||||
|
$table->index(['status', 'sort'], 'idx_mini_home_special_status_sort');
|
||||||
|
$table->comment('小程序首页特价推荐商品');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mini_home_special');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* 采购单单品对账标记:商品明细「已对账」勾选入库持久化(替代原前端 localStorage 本地标记)
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('purchase_item_check')) {
|
||||||
|
Schema::create('purchase_item_check', function (Blueprint $table) {
|
||||||
|
$table->increments('id')->comment('标记ID');
|
||||||
|
$table->integer('purchase_id')->comment('采购单ID');
|
||||||
|
$table->integer('product_id')->comment('商品ID');
|
||||||
|
$table->integer('operator_id')->default(0)->comment('标记人(后台系统用户ID)');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['purchase_id', 'product_id'], 'purchase_item_check_unique');
|
||||||
|
$table->comment('采购单单品对账标记表(商品明细「已对账」勾选)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('purchase_item_check');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -89,6 +89,8 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
|
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
|
||||||
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
|
||||||
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
|
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
|
||||||
|
['type' => 'rule', 'key' => 'product.goods.import', 'name' => '导入'],
|
||||||
|
['type' => 'rule', 'key' => 'product.goods.export', 'name' => '导出'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -135,17 +137,6 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'customer.level.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'customer.level.delete', 'name' => '删除'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'type' => 'route',
|
|
||||||
'key' => 'customer.miniUser',
|
|
||||||
'name' => '小程序用户',
|
|
||||||
'path' => '/customer/mini-user',
|
|
||||||
'children' => [
|
|
||||||
['type' => 'rule', 'key' => 'customer.miniUser.query', 'name' => '查询'],
|
|
||||||
['type' => 'rule', 'key' => 'customer.miniUser.update', 'name' => '启用停用'],
|
|
||||||
['type' => 'rule', 'key' => 'customer.miniUser.bind', 'name' => '绑定主体'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'type' => 'route',
|
'type' => 'route',
|
||||||
'key' => 'customer.notice',
|
'key' => 'customer.notice',
|
||||||
@@ -280,6 +271,18 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => 'route',
|
||||||
|
'key' => 'client.special',
|
||||||
|
'name' => '特价推荐',
|
||||||
|
'path' => '/client/special',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'rule', 'key' => 'client.special.query', 'name' => '查询'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.create', 'name' => '新增'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.update', 'name' => '编辑'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.delete', 'name' => '删除'],
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -16,22 +16,52 @@ class SysDataSeeder extends Seeder
|
|||||||
// 系统设置初始数据
|
// 系统设置初始数据
|
||||||
DB::table('sys_site_config_group')->insert([
|
DB::table('sys_site_config_group')->insert([
|
||||||
['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date],
|
['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 2, 'title' => '小程序设置', 'key' => 'wechatMini', 'remark' => '小程序设置', 'created_at' => $date, 'updated_at' => $date],
|
['id' => 3, 'title' => '业务配置', 'key' => 'services', 'remark' => '业务附加配置', 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 3, 'title' => '业务配置', 'key' => 'services', 'remark' => '小程序设置', 'created_at' => $date, 'updated_at' => $date],
|
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '收款方式与在线支付渠道配置', 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '网站的支付配置', 'created_at' => $date, 'updated_at' => $date],
|
['id' => 5, 'title' => '旺铺支付', 'key' => 'wangpu', 'remark' => '旺铺支付网关配置(进件入网后由旺铺分配)', 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 6, 'title' => '微信官方支付', 'key' => 'wxpay', 'remark' => '微信支付 APIv3 商户配置', 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 7, 'title' => '微信应用配置', 'key' => 'wechat', 'remark' => '微信小程序/公众号应用凭证配置', 'created_at' => $date, 'updated_at' => $date],
|
||||||
]);
|
]);
|
||||||
DB::table('sys_site_config_items')->insert([
|
DB::table('sys_site_config_items')->insert([
|
||||||
['id' => 1, 'group_id' => 1, 'key' => 'title', 'title' => '网站标题', 'describe' => '网站标题,用于展示在网站logo旁边和登录页面以及网页title中', 'values' => 'Xin Admin', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date,],
|
['id' => 1, 'group_id' => 1, 'key' => 'title', 'title' => '网站标题', 'describe' => '网站标题,用于展示在网站logo旁边和登录页面以及网页title中', 'values' => 'Xin Admin', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date,],
|
||||||
['id' => 2, 'group_id' => 1, 'key' => 'logo', 'title' => '网站LOGO', 'describe' => '网站的LOGO,用于标识网站', 'values' => 'https://file.xinadmin.cn/file/favicons.ico', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date,],
|
['id' => 2, 'group_id' => 1, 'key' => 'logo', 'title' => '网站LOGO', 'describe' => '网站的LOGO,用于标识网站', 'values' => 'https://file.xinadmin.cn/file/favicons.ico', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date,],
|
||||||
['id' => 3, 'group_id' => 1, 'key' => 'subtitle', 'title' => '网站副标题', 'describe' => '网站副标题,展示在登录页面标题的下面', 'values' => 'Xin Admin 快速开发框架', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date,],
|
['id' => 3, 'group_id' => 1, 'key' => 'subtitle', 'title' => '网站副标题', 'describe' => '网站副标题,展示在登录页面标题的下面', 'values' => 'Xin Admin 快速开发框架', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date,],
|
||||||
['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 5, 'group_id' => 2, 'key' => 'appid', 'title' => 'APPID', 'describe' => '小程序的APPID', 'values' => '没有描述', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
|
||||||
['id' => 6, 'group_id' => 2, 'key' => 'secret', 'title' => 'SecretKey', 'describe' => '小程序的SecretKey', 'values' => '没有描述', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
|
||||||
['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 36, 'group_id' => 3, 'key' => 'order_time_start', 'title' => '下单开始时间', 'describe' => '允许下单的开始时间(24小时制 HH:mm,如 08:00),留空不限制;开始时间晚于截单时间表示跨天时段(如 20:00 至次日 06:00)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 37, 'group_id' => 3, 'key' => 'order_time_end', 'title' => '截单时间', 'describe' => '下单截止时间(24小时制 HH:mm,如 18:00),不在下单时段内门店不能下单,留空不限制', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
// 支付配置(pay):收款方式与在线支付渠道
|
||||||
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 21, 'group_id' => 4, 'key' => 'online_channel', 'title' => '在线支付渠道', 'describe' => '小程序在线支付通道(二选一):旺铺支付网关 或 微信官方支付(APIv3),切换仅影响之后发起的支付单', 'values' => 'wangpu', 'type' => 'Radio','options' => "wangpu=旺铺支付\nwechat=微信官方支付", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 29, 'group_id' => 4, 'key' => 'pay_methods', 'title' => '支付方式开关', 'describe' => '小程序付款页启用的支付方式(勾选展示),接口 /mini/payment/config 返回 pay_methods 供小程序端控制展示', 'values' => '["wechat_qrcode","alipay_qrcode","bank","online"]', 'type' => 'Checkbox','options' => "wechat_qrcode=微信收款码\nalipay_qrcode=支付宝收款码\nbank=对公汇款\nonline=在线支付", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
// 旺铺支付(wangpu):旺铺支付网关
|
||||||
|
['id' => 12, 'group_id' => 5, 'key' => 'base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 13, 'group_id' => 5, 'key' => 'organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 14, 'group_id' => 5, 'key' => 'mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 15, 'group_id' => 5, 'key' => 'mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 16, 'group_id' => 5, 'key' => 'term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 17, 'group_id' => 5, 'key' => 'public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 34, 'group_id' => 5, 'key' => 'mp_payway_code', 'title' => '旺铺公众号支付方式代码', 'describe' => '公众号 H5 场景支付方式代码 payway_code(如 WECHAT_JSPAY),留空则回退小程序支付方式代码', 'values' => 'WECHAT_JSPAY', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 35, 'group_id' => 5, 'key' => 'sign_key', 'title' => '旺铺通知加签Key', 'describe' => '支付/退款后台通知 MD5 专用加签Key(旺铺分配),用于校验通知报文签名 sign,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
// 微信官方支付(wxpay):微信支付 APIv3
|
||||||
|
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 23, 'group_id' => 6, 'key' => 'appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 24, 'group_id' => 6, 'key' => 'private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 25, 'group_id' => 6, 'key' => 'certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 26, 'group_id' => 6, 'key' => 'secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 27, 'group_id' => 6, 'key' => 'platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准)', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 28, 'group_id' => 6, 'key' => 'platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
// 微信应用配置(wechat):小程序与公众号应用凭证
|
||||||
|
['id' => 30, 'group_id' => 7, 'key' => 'mini_appid', 'title' => '小程序AppID', 'describe' => '微信小程序 AppID(wx.login 换 openid、在线支付下单使用),留空回退 env WECHAT_MINI_APPID', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 31, 'group_id' => 7, 'key' => 'mini_secret', 'title' => '小程序AppSecret', 'describe' => '微信小程序 AppSecret(code2session 登录凭证校验),请勿泄露,留空回退 env WECHAT_MINI_SECRET', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 32, 'group_id' => 7, 'key' => 'mp_appid', 'title' => '公众号AppID', 'describe' => '微信公众号 AppID(公众号消息/网页授权等扩展能力使用)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 33, 'group_id' => 7, 'key' => 'mp_secret', 'title' => '公众号AppSecret', 'describe' => '微信公众号 AppSecret,请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||||
]);
|
]);
|
||||||
// 字典类型初始数据
|
// 字典类型初始数据
|
||||||
DB::table('sys_dict')->insert([
|
DB::table('sys_dict')->insert([
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ class SysSiteConfigItemsController extends BaseController
|
|||||||
SysSiteConfigService::refreshSiteConfig();
|
SysSiteConfigService::refreshSiteConfig();
|
||||||
return $this->success();
|
return $this->success();
|
||||||
}
|
}
|
||||||
$message = '设置保存失败:' . $result['message'];
|
$message = '设置保存失败';
|
||||||
return $this->error($message);
|
return $this->error($result['errors'], $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新设置 */
|
/** 刷新设置 */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user