Compare commits
55 Commits
398bb98356
...
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 | |||
| 2eb27127c9 | |||
| 7517d4a023 | |||
| 8368c51e3f | |||
| cd35213ae6 | |||
| 7c3f6147ac | |||
| 4bdaf2a219 | |||
| 228212f8e3 |
@@ -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 上溯取根分类(商品含软删除,保证历史账单可导出),
|
||||||
* 仅保留根分类为所选分类的行
|
* 仅保留根分类为所选分类的行
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\ContainerReturnModel;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压回筐记录汇总导出:行=日期(区间内逐日),列=门店,
|
||||||
|
* 单元格=当日该门店抵扣(附加)金额合计(同日多条记录合并),无记录填 0;
|
||||||
|
* 末列为行合计,末行为列合计(右下角为总计)
|
||||||
|
*
|
||||||
|
* WithStrictNullComparison:PhpSpreadsheet fromArray 默认松散比较(0 == null 为 true),
|
||||||
|
* 数值 0 的单元格会被跳过写成空白,必须启用严格比较才能把 0 填进表格
|
||||||
|
*/
|
||||||
|
class ContainerReturnExport implements FromCollection, WithStyles, WithStrictNullComparison
|
||||||
|
{
|
||||||
|
/** 门店列填充色(浅蓝) */
|
||||||
|
private const string STORE_FILL = 'FFDDEBF7';
|
||||||
|
|
||||||
|
/** 合计列填充色(浅红) */
|
||||||
|
private const string AMOUNT_FILL = 'FFFFCCCC';
|
||||||
|
|
||||||
|
/** 金额格式:¥ + 两位小数 */
|
||||||
|
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
|
||||||
|
|
||||||
|
private ?Collection $rows = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/total) */
|
||||||
|
private array $specialRows = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{stores: array<int, float>, total: float}> 日期行索引 => 填色判断数据 */
|
||||||
|
private array $rowData = [];
|
||||||
|
|
||||||
|
/** @var array{stores: array<int, float>, total: float} 合计行填色判断数据 */
|
||||||
|
private array $summaryTotals = ['stores' => [], 'total' => 0.0];
|
||||||
|
|
||||||
|
/** 列头所在行索引 */
|
||||||
|
private int $headerRow = 1;
|
||||||
|
|
||||||
|
/** 数据末行索引 */
|
||||||
|
private int $lastRow = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, ContainerReturnModel> $records 区间内压回筐记录
|
||||||
|
* @param array<int, string> $storeNames 门店ID => 名称(导出列,升序)
|
||||||
|
* @param string $startDate 开始日期 Y-m-d
|
||||||
|
* @param string $endDate 结束日期 Y-m-d
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Collection $records,
|
||||||
|
private readonly array $storeNames,
|
||||||
|
private readonly string $startDate,
|
||||||
|
private readonly string $endDate,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出行:标题/范围/列头/日期矩阵/合计全部手工构建(行位置不固定,不用 WithHeadings)
|
||||||
|
*/
|
||||||
|
public function collection(): Collection
|
||||||
|
{
|
||||||
|
if ($this->rows !== null) {
|
||||||
|
return $this->rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同日期同门店多条记录合并:金额代数和(正=压筐附加,负=回筐抵扣)
|
||||||
|
$amounts = [];
|
||||||
|
foreach ($this->records as $record) {
|
||||||
|
$date = $record->created_at->format('Y-m-d');
|
||||||
|
$amounts[$date][(int) $record->store_id] = bcadd(
|
||||||
|
$amounts[$date][(int) $record->store_id] ?? '0',
|
||||||
|
(string) $record->amount,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$rowIndex = 0;
|
||||||
|
|
||||||
|
// 标题行
|
||||||
|
$rows[] = ['压回筐记录汇总(' . $this->startDate . ' ~ ' . $this->endDate . ')'];
|
||||||
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
|
// 范围行:门店 / 导出时间
|
||||||
|
$rows[] = ['门店:' . implode('、', array_values($this->storeNames)) . ' 导出时间:' . now('Asia/Shanghai')->format('Y-m-d H:i:s')];
|
||||||
|
$this->specialRows[++$rowIndex] = 'scope';
|
||||||
|
|
||||||
|
// 空行
|
||||||
|
$rows[] = [''];
|
||||||
|
$rowIndex++;
|
||||||
|
|
||||||
|
// 列头:日期 + 门店列 + 行合计
|
||||||
|
$rows[] = array_merge(['日期'], array_values($this->storeNames), ['合计']);
|
||||||
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
|
// 日期矩阵:区间内逐日一行,当天门店无记录填 0
|
||||||
|
$storeIds = array_map(intval(...), array_keys($this->storeNames));
|
||||||
|
$columnTotals = array_fill_keys($storeIds, '0');
|
||||||
|
$grandTotal = '0';
|
||||||
|
foreach (CarbonPeriod::create($this->startDate, $this->endDate) as $date) {
|
||||||
|
$dateKey = $date->format('Y-m-d');
|
||||||
|
$row = [$dateKey];
|
||||||
|
$rowTotal = '0';
|
||||||
|
$storeValues = [];
|
||||||
|
foreach ($storeIds as $storeId) {
|
||||||
|
$amount = $amounts[$dateKey][$storeId] ?? '0.00';
|
||||||
|
$row[] = (float) $amount;
|
||||||
|
$storeValues[$storeId] = (float) $amount;
|
||||||
|
$rowTotal = bcadd($rowTotal, $amount, 2);
|
||||||
|
$columnTotals[$storeId] = bcadd($columnTotals[$storeId], $amount, 2);
|
||||||
|
}
|
||||||
|
$grandTotal = bcadd($grandTotal, $rowTotal, 2);
|
||||||
|
$row[] = (float) $rowTotal;
|
||||||
|
$rows[] = $row;
|
||||||
|
$this->rowData[++$rowIndex] = ['stores' => $storeValues, 'total' => (float) $rowTotal];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合计行:各门店列合计 + 总计
|
||||||
|
$totalRow = ['合计'];
|
||||||
|
foreach ($storeIds as $storeId) {
|
||||||
|
$totalRow[] = (float) $columnTotals[$storeId];
|
||||||
|
}
|
||||||
|
$totalRow[] = (float) $grandTotal;
|
||||||
|
$rows[] = $totalRow;
|
||||||
|
$this->specialRows[++$rowIndex] = 'total';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = [
|
||||||
|
'stores' => array_map(floatval(...), $columnTotals),
|
||||||
|
'total' => (float) $grandTotal,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->rows = collect($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题/列头/合计行加粗;全表居中 + 全边框;金额区货币格式;
|
||||||
|
* 门店列(浅蓝)/合计列(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头行
|
||||||
|
*/
|
||||||
|
public function styles(Worksheet $sheet): array
|
||||||
|
{
|
||||||
|
$this->collection();
|
||||||
|
|
||||||
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
|
$sheet->getColumnDimension('A')->setWidth(12);
|
||||||
|
$columnCount = count($this->storeNames) + 2;
|
||||||
|
for ($column = 2; $column <= $columnCount; $column++) {
|
||||||
|
$sheet->getColumnDimensionByColumn($column)->setWidth(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storeIds = array_map(intval(...), array_keys($this->storeNames));
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||||
|
|
||||||
|
// 全部单元格水平/垂直居中
|
||||||
|
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
// 表格区域(列头 → 合计行)添加所有边框
|
||||||
|
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN);
|
||||||
|
|
||||||
|
// 金额区(门店列 + 合计列)货币格式:¥ + 两位小数
|
||||||
|
$sheet->getStyle('B' . ($this->headerRow + 1) . ':' . $lastColumn . $this->lastRow)
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode(self::CURRENCY_FORMAT);
|
||||||
|
|
||||||
|
// 门店列(B 起):非 0 浅蓝,0 无背景,列头固定浅蓝
|
||||||
|
foreach ($storeIds as $i => $storeId) {
|
||||||
|
$column = Coordinate::stringFromColumnIndex(2 + $i);
|
||||||
|
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if (($data['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (($this->summaryTotals['stores'][$storeId] ?? 0.0) !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合计列(末列):非 0 浅红,0 无背景,列头固定浅红
|
||||||
|
$this->fillCell($sheet, $lastColumn . $this->headerRow, self::AMOUNT_FILL);
|
||||||
|
foreach ($this->rowData as $row => $data) {
|
||||||
|
if ($data['total'] !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $lastColumn . $row, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->summaryTotals['total'] !== 0.0) {
|
||||||
|
$this->fillCell($sheet, $lastColumn . $this->lastRow, self::AMOUNT_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
$styles = [];
|
||||||
|
foreach ($this->specialRows as $row => $type) {
|
||||||
|
$style = match ($type) {
|
||||||
|
'title' => ['font' => ['bold' => true, 'size' => 14]],
|
||||||
|
'header', 'total' => ['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,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
-109
@@ -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,46 +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 string $type all 全品类 / category 仅蔬果分类
|
* @param int $supplierId 供应商筛选(0=全部)
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PurchaseOrderModel $purchase,
|
private readonly PurchaseOrderModel $purchase,
|
||||||
private readonly string $type = 'all',
|
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)
|
||||||
@@ -63,142 +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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出范围:系统全部未删除商品(含下架)∪ 本采购单有订货的商品(含已删)
|
||||||
|
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows[] = [
|
$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('该供应商在系统中无商品行,无法导出');
|
||||||
|
|
||||||
if ($this->type === 'category') {
|
|
||||||
$items = $items->filter(function (array $row): bool {
|
|
||||||
$rootName = $this->categoryNames[$row['product_id']] ?? '';
|
|
||||||
return str_contains($rootName, '蔬菜') || str_contains($rootName, '水果');
|
|
||||||
})->values();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$sort = 1;
|
// ===== 手工建行 =====
|
||||||
return $this->items = $items->map(static function (array $row) use (&$sort): array {
|
$rows = [];
|
||||||
$row['sort'] = $sort++;
|
$rowIndex = 0;
|
||||||
return $row;
|
|
||||||
})->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function headings(): array
|
// 标题行
|
||||||
{
|
$rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')'];
|
||||||
$this->collection();
|
$this->specialRows[++$rowIndex] = 'title';
|
||||||
|
|
||||||
return array_merge(
|
// 范围行:供应商筛选 / 导出时间
|
||||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
$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;
|
||||||
|
|
||||||
public function map($row): array
|
// 明细行
|
||||||
{
|
$totalQuantity = '0';
|
||||||
return array_merge([
|
$totalWeight = '0';
|
||||||
$row['sort'],
|
$totalAmount = '0';
|
||||||
$this->categoryNames[$row['product_id']] ?? '',
|
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
|
||||||
$row['product_name'],
|
foreach (array_values($items) as $sort => $item) {
|
||||||
$this->supplierNames[$row['supplier_id']] ?? '',
|
$this->rowData[++$rowIndex] = [
|
||||||
$row['product_spec'],
|
'quantity' => $item['quantity'],
|
||||||
$row['unit'],
|
'amount' => $item['amount'],
|
||||||
$row['cost_price'],
|
'store_quantities' => $item['store_quantities'],
|
||||||
$row['unit_cost'],
|
];
|
||||||
$row['quantity'],
|
$rows[] = array_merge([
|
||||||
$row['weight'],
|
$sort + 1,
|
||||||
$row['amount'],
|
$item['category'],
|
||||||
], array_values($row['store_quantities']));
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合计行(行合计 + 门店列合计;门店无数据显示空白)
|
||||||
|
$rows[] = array_merge(
|
||||||
|
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
||||||
|
array_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
|
||||||
|
);
|
||||||
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
$this->lastRow = $rowIndex;
|
||||||
|
$this->summaryTotals = [
|
||||||
|
'quantity' => (float) $totalQuantity,
|
||||||
|
'amount' => (float) $totalAmount,
|
||||||
|
'stores' => $storeTotals,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->rows = collect($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表头加粗 + 冻结首行
|
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
|
||||||
|
* 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
|
||||||
*/
|
*/
|
||||||
public function styles(Worksheet $sheet): array
|
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;
|
||||||
@@ -211,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\BillModel;
|
||||||
|
use App\Models\PaymentModel;
|
||||||
|
use App\Models\ProductCategoryModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
use App\Models\StoreOrderItemModel;
|
||||||
|
use App\Models\StoreOrderModel;
|
||||||
|
use App\Models\SupplierModel;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘统计(分析页 /dashboard/analysis 数据源)
|
||||||
|
*
|
||||||
|
* 口径约定:
|
||||||
|
* - 销售/订单/品类/热销等统计均排除「已取消」订单与软删除订单
|
||||||
|
* - 采购金额按实际采购价计(actual_amount 为 0 时退回预估价 estimate_amount)
|
||||||
|
* - 应收余额 = 账单总额 - 已支付账单金额(审核中回款不计入已收)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/dashboard', 'dashboard')]
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
/** 销售趋势统计天数 */
|
||||||
|
private const int TREND_DAYS = 30;
|
||||||
|
|
||||||
|
/** 排行榜条数上限 */
|
||||||
|
private const int TOP_LIMIT = 10;
|
||||||
|
|
||||||
|
/** 最新订单条数 */
|
||||||
|
private const int LATEST_ORDER_LIMIT = 8;
|
||||||
|
|
||||||
|
/** 分析页聚合数据(单接口一次返回,减少首页请求数) */
|
||||||
|
#[GetRoute('/analysis', 'analysis')]
|
||||||
|
public function analysis(): JsonResponse
|
||||||
|
{
|
||||||
|
$today = now()->toDateString();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'overview' => $this->overview(),
|
||||||
|
'trend' => $this->salesTrend($today),
|
||||||
|
'order_status' => $this->orderStatusDistribution(),
|
||||||
|
'category_sales' => $this->categorySales(),
|
||||||
|
'top_products' => $this->topProducts(),
|
||||||
|
'top_stores' => $this->topStores(),
|
||||||
|
'recon' => $this->reconSummary(),
|
||||||
|
'latest_orders' => $this->latestOrders(),
|
||||||
|
'archives' => $this->archives(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心指标卡:销售额 / 订单数 / 采购金额(累计、今日、近7天、环比、7日迷你趋势)+ 应收余额
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* sales: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
|
||||||
|
* orders: array{total: int, today: int, week: int, growth: float|null, trend7: int[]},
|
||||||
|
* purchase: array{total: float, today: float, week: float, growth: float|null, trend7: float[]},
|
||||||
|
* receivable: array{bill_total: float, received: float, unreceived: float}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function overview(): array
|
||||||
|
{
|
||||||
|
$today = now()->toDateString();
|
||||||
|
$weekStart = now()->subDays(6)->toDateString();
|
||||||
|
$lastWeekStart = now()->subDays(13)->toDateString();
|
||||||
|
$lastWeekEnd = now()->subDays(7)->toDateString();
|
||||||
|
|
||||||
|
// 近14天按日聚合(前7天用于环比,后7天用于迷你趋势图)
|
||||||
|
$daily = $this->validOrderQuery()
|
||||||
|
->whereBetween('order_date', [$lastWeekStart, $today])
|
||||||
|
->get(['order_date', 'total_amount'])
|
||||||
|
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
|
||||||
|
|
||||||
|
$salesByDay = [];
|
||||||
|
$ordersByDay = [];
|
||||||
|
foreach ($daily as $date => $orders) {
|
||||||
|
$salesByDay[$date] = (float) $orders->sum('total_amount');
|
||||||
|
$ordersByDay[$date] = $orders->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
$salesWeek = $this->sumRange($salesByDay, $weekStart, $today);
|
||||||
|
$salesLastWeek = $this->sumRange($salesByDay, $lastWeekStart, $lastWeekEnd);
|
||||||
|
$ordersWeek = (int) $this->sumRange($ordersByDay, $weekStart, $today);
|
||||||
|
$ordersLastWeek = (int) $this->sumRange($ordersByDay, $lastWeekStart, $lastWeekEnd);
|
||||||
|
|
||||||
|
// 采购金额:实际价优先,无实际价用预估价
|
||||||
|
$purchaseDaily = PurchaseOrderModel::query()
|
||||||
|
->whereBetween('purchase_date', [$lastWeekStart, $today])
|
||||||
|
->get(['purchase_date', 'estimate_amount', 'actual_amount'])
|
||||||
|
->groupBy(fn (PurchaseOrderModel $purchase) => $purchase->purchase_date->toDateString())
|
||||||
|
->map(fn ($purchases) => (float) $purchases->sum(
|
||||||
|
fn (PurchaseOrderModel $purchase) => (float) $purchase->actual_amount > 0
|
||||||
|
? $purchase->actual_amount
|
||||||
|
: $purchase->estimate_amount
|
||||||
|
));
|
||||||
|
|
||||||
|
$purchaseWeek = $this->sumRange($purchaseDaily->all(), $weekStart, $today);
|
||||||
|
$purchaseLastWeek = $this->sumRange($purchaseDaily->all(), $lastWeekStart, $lastWeekEnd);
|
||||||
|
|
||||||
|
// 应收余额:账单总额 - 已支付账单金额
|
||||||
|
$billTotal = (float) BillModel::query()->sum('total_amount');
|
||||||
|
$received = (float) BillModel::query()->where('status', BillModel::STATUS_PAID)->sum('total_amount');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sales' => [
|
||||||
|
'total' => $this->money($this->validOrderQuery()->sum('total_amount')),
|
||||||
|
'today' => $this->money($salesByDay[$today] ?? 0),
|
||||||
|
'week' => $this->money($salesWeek),
|
||||||
|
'growth' => $this->growthRate($salesWeek, $salesLastWeek),
|
||||||
|
'trend7' => array_map(
|
||||||
|
fn ($day) => $this->money($salesByDay[$day] ?? 0),
|
||||||
|
$this->dateRange($weekStart, $today)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'orders' => [
|
||||||
|
'total' => (int) $this->validOrderQuery()->count(),
|
||||||
|
'today' => (int) ($ordersByDay[$today] ?? 0),
|
||||||
|
'week' => $ordersWeek,
|
||||||
|
'growth' => $this->growthRate((float) $ordersWeek, (float) $ordersLastWeek),
|
||||||
|
'trend7' => array_map(
|
||||||
|
static fn ($day) => (int) ($ordersByDay[$day] ?? 0),
|
||||||
|
$this->dateRange($weekStart, $today)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'purchase' => [
|
||||||
|
'total' => $this->money(PurchaseOrderModel::query()->sum(DB::raw(
|
||||||
|
'CASE WHEN actual_amount > 0 THEN actual_amount ELSE COALESCE(estimate_amount, 0) END'
|
||||||
|
))),
|
||||||
|
'today' => $this->money($purchaseDaily[$today] ?? 0),
|
||||||
|
'week' => $this->money($purchaseWeek),
|
||||||
|
'growth' => $this->growthRate($purchaseWeek, $purchaseLastWeek),
|
||||||
|
'trend7' => array_map(
|
||||||
|
fn ($day) => $this->money($purchaseDaily[$day] ?? 0),
|
||||||
|
$this->dateRange($weekStart, $today)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'receivable' => [
|
||||||
|
'bill_total' => $this->money($billTotal),
|
||||||
|
'received' => $this->money($received),
|
||||||
|
'unreceived' => $this->money($billTotal - $received),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 近30天销售趋势(缺日补0)
|
||||||
|
*
|
||||||
|
* @return array<int, array{date: string, amount: float, orders: int}>
|
||||||
|
*/
|
||||||
|
private function salesTrend(string $today): array
|
||||||
|
{
|
||||||
|
$start = now()->subDays(self::TREND_DAYS - 1)->toDateString();
|
||||||
|
|
||||||
|
$daily = $this->validOrderQuery()
|
||||||
|
->whereBetween('order_date', [$start, $today])
|
||||||
|
->get(['order_date', 'total_amount'])
|
||||||
|
->groupBy(fn (StoreOrderModel $order) => $order->order_date->toDateString());
|
||||||
|
|
||||||
|
return array_map(static function ($date) use ($daily) {
|
||||||
|
$orders = $daily->get($date);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'amount' => round((float) ($orders?->sum('total_amount') ?? 0), 2),
|
||||||
|
'orders' => $orders?->count() ?? 0,
|
||||||
|
];
|
||||||
|
}, $this->dateRange($start, $today));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单状态分布(含已取消,全部状态固定输出,缺省补0)
|
||||||
|
*
|
||||||
|
* @return array<int, array{status: int, name: string, count: int}>
|
||||||
|
*/
|
||||||
|
private function orderStatusDistribution(): array
|
||||||
|
{
|
||||||
|
$counts = StoreOrderModel::query()
|
||||||
|
->selectRaw('status, COUNT(*) as cnt')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('cnt', 'status');
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach (StoreOrderModel::STATUS_NAMES as $status => $name) {
|
||||||
|
$result[] = [
|
||||||
|
'status' => $status,
|
||||||
|
'name' => $name,
|
||||||
|
'count' => (int) ($counts[$status] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 品类销售占比(按明细金额汇总,金额降序)
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, amount: float}>
|
||||||
|
*/
|
||||||
|
private function categorySales(): array
|
||||||
|
{
|
||||||
|
$rows = $this->validItemQuery()
|
||||||
|
->groupBy('i.category_id')
|
||||||
|
->selectRaw('i.category_id, SUM(i.amount) as amount')
|
||||||
|
->orderByDesc('amount')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$categoryNames = ProductCategoryModel::query()->pluck('name', 'id');
|
||||||
|
|
||||||
|
return $rows->map(static fn ($row) => [
|
||||||
|
'name' => $categoryNames[$row->category_id] ?? '未分类',
|
||||||
|
'amount' => round((float) $row->amount, 2),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 热销商品 TOP10(按明细金额汇总;商品名/规格/单位取快照)
|
||||||
|
*
|
||||||
|
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, quantity: float, amount: float}>
|
||||||
|
*/
|
||||||
|
private function topProducts(): array
|
||||||
|
{
|
||||||
|
return $this->validItemQuery()
|
||||||
|
->groupBy('i.product_id')
|
||||||
|
->selectRaw(implode(', ', [
|
||||||
|
'i.product_id',
|
||||||
|
'MAX(i.product_name) as product_name',
|
||||||
|
'MAX(i.product_spec) as product_spec',
|
||||||
|
'MAX(i.unit) as unit',
|
||||||
|
'SUM(i.quantity) as quantity',
|
||||||
|
'SUM(i.amount) as amount',
|
||||||
|
]))
|
||||||
|
->orderByDesc('amount')
|
||||||
|
->limit(self::TOP_LIMIT)
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row) => [
|
||||||
|
'product_id' => (int) $row->product_id,
|
||||||
|
'product_name' => (string) $row->product_name,
|
||||||
|
'product_spec' => (string) $row->product_spec,
|
||||||
|
'unit' => (string) $row->unit,
|
||||||
|
'quantity' => round((float) $row->quantity, 2),
|
||||||
|
'amount' => round((float) $row->amount, 2),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店排行 TOP10(按订单金额汇总,含软删除门店名称回查)
|
||||||
|
*
|
||||||
|
* @return array<int, array{store_id: int, store_name: string, order_count: int, amount: float}>
|
||||||
|
*/
|
||||||
|
private function topStores(): array
|
||||||
|
{
|
||||||
|
$rows = $this->validOrderQuery()
|
||||||
|
->groupBy('store_id')
|
||||||
|
->selectRaw('store_id, COUNT(*) as order_count, SUM(total_amount) as amount')
|
||||||
|
->orderByDesc('amount')
|
||||||
|
->limit(self::TOP_LIMIT)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$storeNames = StoreModel::withTrashed()
|
||||||
|
->whereIn('id', $rows->pluck('store_id'))
|
||||||
|
->pluck('name', 'id');
|
||||||
|
|
||||||
|
return $rows->map(static fn ($row) => [
|
||||||
|
'store_id' => (int) $row->store_id,
|
||||||
|
'store_name' => $storeNames[$row->store_id] ?? '未知门店',
|
||||||
|
'order_count' => (int) $row->order_count,
|
||||||
|
'amount' => round((float) $row->amount, 2),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对账与回款概览:账单支付进度分布 + 待审核回款
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* bills: array<int, array{pay_state: int, name: string, count: int, amount: float}>,
|
||||||
|
* pending_payment: array{count: int, amount: float}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function reconSummary(): array
|
||||||
|
{
|
||||||
|
$states = [];
|
||||||
|
foreach (BillModel::PAY_STATE_NAMES as $state => $name) {
|
||||||
|
$states[$state] = ['pay_state' => $state, 'name' => $name, 'count' => 0, 'amount' => 0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
BillModel::query()
|
||||||
|
->get(['id', 'total_amount', 'status', 'payment_id'])
|
||||||
|
->each(static function (BillModel $bill) use (&$states) {
|
||||||
|
$state = $bill->payState();
|
||||||
|
$states[$state]['count']++;
|
||||||
|
$states[$state]['amount'] += (float) $bill->total_amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
$bills = array_values(array_map(
|
||||||
|
fn ($state) => [
|
||||||
|
'pay_state' => $state['pay_state'],
|
||||||
|
'name' => $state['name'],
|
||||||
|
'count' => $state['count'],
|
||||||
|
'amount' => $this->money($state['amount']),
|
||||||
|
],
|
||||||
|
$states
|
||||||
|
));
|
||||||
|
|
||||||
|
$pendingPayment = PaymentModel::query()->where('status', PaymentModel::STATUS_PENDING);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bills' => $bills,
|
||||||
|
'pending_payment' => [
|
||||||
|
'count' => (int) $pendingPayment->count(),
|
||||||
|
'amount' => $this->money($pendingPayment->sum('amount')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新订单(按下单日期倒序,含已取消,便于掌握最新动态)
|
||||||
|
*
|
||||||
|
* @return array<int, array{id: int, order_no: string, store_name: string, order_date: string, total_amount: float, status: int, status_name: string}>
|
||||||
|
*/
|
||||||
|
private function latestOrders(): array
|
||||||
|
{
|
||||||
|
return StoreOrderModel::query()
|
||||||
|
->with('store:id,name')
|
||||||
|
->orderByDesc('order_date')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(self::LATEST_ORDER_LIMIT)
|
||||||
|
->get()
|
||||||
|
->map(static fn (StoreOrderModel $order) => [
|
||||||
|
'id' => $order->id,
|
||||||
|
'order_no' => $order->order_no,
|
||||||
|
'store_name' => $order->store->name ?? '未知门店',
|
||||||
|
'order_date' => $order->order_date->toDateString(),
|
||||||
|
'total_amount' => round((float) $order->total_amount, 2),
|
||||||
|
'status' => $order->status,
|
||||||
|
'status_name' => StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础档案计数:在营门店 / 在售商品 / 合作供应商
|
||||||
|
*
|
||||||
|
* @return array{stores: int, products: int, suppliers: int}
|
||||||
|
*/
|
||||||
|
private function archives(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stores' => StoreModel::query()->where('status', StoreModel::STATUS_NORMAL)->count(),
|
||||||
|
'products' => ProductModel::query()->where('status', ProductModel::STATUS_ON)->count(),
|
||||||
|
'suppliers' => SupplierModel::query()->where('status', SupplierModel::STATUS_NORMAL)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有效订单查询(排除已取消;软删除由模型全局作用域排除) */
|
||||||
|
private function validOrderQuery(): Builder
|
||||||
|
{
|
||||||
|
return StoreOrderModel::query()->where('status', '<>', StoreOrderModel::STATUS_CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有效订单明细查询(join 绕过模型全局作用域,需手动排除软删除订单与已取消订单)
|
||||||
|
*/
|
||||||
|
private function validItemQuery(): Builder
|
||||||
|
{
|
||||||
|
return StoreOrderItemModel::query()
|
||||||
|
->from('store_order_item as i')
|
||||||
|
->join('store_order as o', 'o.id', '=', 'i.order_id')
|
||||||
|
->whereNull('o.deleted_at')
|
||||||
|
->where('o.status', '<>', StoreOrderModel::STATUS_CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按日期区间(含端点)汇总每日数据,缺日按0计
|
||||||
|
*
|
||||||
|
* @param array<string, float|int> $daily 日期 => 当日值
|
||||||
|
*/
|
||||||
|
private function sumRange(array $daily, string $start, string $end): float
|
||||||
|
{
|
||||||
|
$sum = 0.0;
|
||||||
|
foreach ($this->dateRange($start, $end) as $date) {
|
||||||
|
$sum += (float) ($daily[$date] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成连续日期列表(含端点)
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function dateRange(string $start, string $end): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$cursor = $start;
|
||||||
|
while ($cursor <= $end) {
|
||||||
|
$dates[] = $cursor;
|
||||||
|
$cursor = date('Y-m-d', strtotime($cursor . ' +1 day'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额统一保留两位 */
|
||||||
|
private function money(float|string|null $value): float
|
||||||
|
{
|
||||||
|
return round((float) $value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环比增长率(%):上期为0时无基准,返回 null
|
||||||
|
*/
|
||||||
|
private function growthRate(float $current, float $previous): ?float
|
||||||
|
{
|
||||||
|
if ($previous <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($current - $previous) / $previous * 100, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,31 +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->broadcast_from');
|
->pluck('data')
|
||||||
|
->map(static fn (?array $data) => $data['broadcast_from'] ?? null)
|
||||||
|
->filter()
|
||||||
|
->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());
|
||||||
}
|
}
|
||||||
@@ -52,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,11 +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\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;
|
||||||
@@ -29,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()
|
||||||
@@ -47,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) {
|
||||||
@@ -63,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,
|
||||||
@@ -89,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' => '',
|
||||||
@@ -107,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,
|
||||||
@@ -118,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;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,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)
|
||||||
@@ -161,7 +184,7 @@ class OrderController extends BaseMiniController
|
|||||||
'total_amount', 'status', 'remark', 'purchase_id', 'bill_id', 'created_at',
|
'total_amount', 'status', 'remark', 'purchase_id', 'bill_id', 'created_at',
|
||||||
])
|
])
|
||||||
->with(['items' => static fn ($itemsQuery) => $itemsQuery
|
->with(['items' => static fn ($itemsQuery) => $itemsQuery
|
||||||
->select(['id', 'order_id', 'product_name', 'quantity', 'unit'])
|
->select(['id', 'order_id', 'product_name', 'product_spec', 'quantity', 'unit', 'image_ids'])
|
||||||
->orderBy('id')]);
|
->orderBy('id')]);
|
||||||
if (isset($params['status'])) {
|
if (isset($params['status'])) {
|
||||||
$query->where('status', (int) $params['status']);
|
$query->where('status', (int) $params['status']);
|
||||||
@@ -177,6 +200,28 @@ class OrderController extends BaseMiniController
|
|||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate((int) ($params['pageSize'] ?? 10));
|
->paginate((int) ($params['pageSize'] ?? 10));
|
||||||
|
|
||||||
|
// 商品预览(每单前 3 条明细):首图跨订单一次性批量解析
|
||||||
|
$previewMap = [];
|
||||||
|
$flatItems = [];
|
||||||
|
foreach ($paginator->getCollection() as $order) {
|
||||||
|
foreach ($order->items->take(3) as $item) {
|
||||||
|
$flatItems[] = [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'product_name' => $item->product_name,
|
||||||
|
'product_spec' => $item->product_spec,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'unit' => $item->unit,
|
||||||
|
'image_ids' => (array) $item->image_ids,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app(ItemImageResolver::class)->resolve($flatItems);
|
||||||
|
foreach ($flatItems as $flatItem) {
|
||||||
|
$orderId = $flatItem['order_id'];
|
||||||
|
unset($flatItem['order_id']);
|
||||||
|
$previewMap[$orderId][] = $flatItem;
|
||||||
|
}
|
||||||
|
|
||||||
$paginator->getCollection()->transform(
|
$paginator->getCollection()->transform(
|
||||||
static fn (StoreOrderModel $order): array => [
|
static fn (StoreOrderModel $order): array => [
|
||||||
'id' => $order->id,
|
'id' => $order->id,
|
||||||
@@ -193,12 +238,7 @@ class OrderController extends BaseMiniController
|
|||||||
'bill_id' => $order->bill_id,
|
'bill_id' => $order->bill_id,
|
||||||
'created_at' => $order->created_at?->toDateTimeString(),
|
'created_at' => $order->created_at?->toDateTimeString(),
|
||||||
'item_count' => $order->items->count(),
|
'item_count' => $order->items->count(),
|
||||||
'items' => $order->items->take(3)
|
'items' => $previewMap[$order->id] ?? [],
|
||||||
->map(static fn (StoreOrderItemModel $item): array => [
|
|
||||||
'product_name' => $item->product_name,
|
|
||||||
'quantity' => $item->quantity,
|
|
||||||
'unit' => $item->unit,
|
|
||||||
])->values()->all(),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -216,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();
|
||||||
@@ -255,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)
|
||||||
@@ -272,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) {
|
||||||
@@ -288,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,36 +47,77 @@ class ProductController extends BaseMiniController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$pageSize = (int) $request->input('pageSize', 10);
|
// 先按分类树展示顺序(与小程序分类栏一致;不在启用分类树中的商品排最后),再按商品排序
|
||||||
$data = $query->orderBy('sort')
|
$categoryIds = ProductCategoryModel::getTreeOrderedIds(true);
|
||||||
->orderBy('id')
|
if ($categoryIds !== []) {
|
||||||
->paginate($pageSize)
|
$cases = [];
|
||||||
->toArray();
|
foreach ($categoryIds as $position => $categoryId) {
|
||||||
|
$cases[] = "WHEN {$categoryId} THEN {$position}";
|
||||||
foreach ($data['data'] as &$row) {
|
|
||||||
$row['price'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户
|
|
||||||
$user = $this->optionalUser($request);
|
|
||||||
if (!$user) return $this->success($data);
|
|
||||||
// 门店
|
|
||||||
$store = $this->boundStore($user);
|
|
||||||
if(!$store) return $this->success($data);
|
|
||||||
|
|
||||||
if ($store->level_id > 0) {
|
|
||||||
foreach ($data['data'] as &$row) {
|
|
||||||
$model = ProductPriceModel::where('product_id', $row['id'])->where('level_id', $store->level_id)->first();
|
|
||||||
$price = ProductPriceModel::calcActualPrice(
|
|
||||||
$model->price_type,
|
|
||||||
$model->price,
|
|
||||||
$model->percent,
|
|
||||||
$row->cost_price,
|
|
||||||
);
|
|
||||||
$row['price'] = $price;
|
|
||||||
}
|
}
|
||||||
|
$query->orderByRaw(
|
||||||
|
'CASE category_id ' . implode(' ', $cases) . ' ELSE ' . count($categoryIds) . ' END'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
|
$paginator = $query->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 (ProductModel $product) use ($level, $cartRows): array {
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品详情(免登录浏览;登录门店按等级上浮比例显示换算价,未登录/未设等级 price=null)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/product/{id}', authorize: false, where: ['id' => '[0-9]+'])]
|
||||||
|
public function detail(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$product = ProductModel::query()
|
||||||
|
->where('status', ProductModel::STATUS_ON)
|
||||||
|
->with('category:id,name')
|
||||||
|
->find($id);
|
||||||
|
if ($product === null) {
|
||||||
|
throw new RepositoryException('商品不存在或已下架');
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = null;
|
||||||
|
$store = $this->optionalStore($request);
|
||||||
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
|
if ($level !== null) {
|
||||||
|
$price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartRows = $store !== null ? app(CartService::class)->cartRowMap($store->id) : [];
|
||||||
|
|
||||||
|
$data = $product->toArray();
|
||||||
|
$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'],
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Order;
|
namespace App\Http\Controllers\Order;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
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;
|
||||||
@@ -38,7 +39,8 @@ class StoreOrderController extends BaseController
|
|||||||
$query = StoreOrderModel::query()->with([
|
$query = StoreOrderModel::query()->with([
|
||||||
'store:id,name,address,contact,phone',
|
'store:id,name,address,contact,phone',
|
||||||
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||||
'purchase:id,purchase_no,purchase_date,status'
|
'purchase:id,purchase_no,purchase_date,status',
|
||||||
|
'bill:id,bill_no,bill_date,total_amount,status,payment_id,paid_at',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||||
@@ -50,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')
|
||||||
@@ -65,6 +76,7 @@ class StoreOrderController extends BaseController
|
|||||||
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
|
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
|
||||||
foreach ($data['data'] as &$order) {
|
foreach ($data['data'] as &$order) {
|
||||||
app(ItemImageResolver::class)->resolve($order['items']);
|
app(ItemImageResolver::class)->resolve($order['items']);
|
||||||
|
$this->appendBillPayState($order);
|
||||||
}
|
}
|
||||||
unset($order);
|
unset($order);
|
||||||
|
|
||||||
@@ -78,6 +90,7 @@ class StoreOrderController extends BaseController
|
|||||||
$order = StoreOrderModel::with([
|
$order = StoreOrderModel::with([
|
||||||
'store:id,name,address,contact,phone',
|
'store:id,name,address,contact,phone',
|
||||||
'items' => static fn ($query) => $query->with('supplier:id,name'),
|
'items' => static fn ($query) => $query->with('supplier:id,name'),
|
||||||
|
'bill',
|
||||||
])->find($id);
|
])->find($id);
|
||||||
if (empty($order)) {
|
if (empty($order)) {
|
||||||
throw new RepositoryException('订单不存在');
|
throw new RepositoryException('订单不存在');
|
||||||
@@ -90,6 +103,8 @@ class StoreOrderController extends BaseController
|
|||||||
|
|
||||||
// 明细首图
|
// 明细首图
|
||||||
app(ItemImageResolver::class)->resolve($data['items']);
|
app(ItemImageResolver::class)->resolve($data['items']);
|
||||||
|
// 关联账单详情:补充支付进度
|
||||||
|
$this->appendBillPayState($data);
|
||||||
|
|
||||||
return $this->success($data);
|
return $this->success($data);
|
||||||
}
|
}
|
||||||
@@ -101,10 +116,10 @@ class StoreOrderController extends BaseController
|
|||||||
public function status(int $id, Request $request): JsonResponse
|
public function status(int $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'status' => 'required|integer|in:1,3,4,9',
|
'status' => 'required|integer|in:1,9',
|
||||||
], [
|
], [
|
||||||
'status.required' => '目标状态不能为空',
|
'status.required' => '目标状态不能为空',
|
||||||
'status.in' => '目标状态值不正确',
|
'status.in' => '目标状态仅支持接单或取消订单',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$order = StoreOrderModel::find($id);
|
$order = StoreOrderModel::find($id);
|
||||||
@@ -136,13 +151,13 @@ class StoreOrderController extends BaseController
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'ids' => 'required|array|min:1',
|
'ids' => 'required|array|min:1',
|
||||||
'ids.*' => 'integer|exists:store_order,id',
|
'ids.*' => 'integer|exists:store_order,id',
|
||||||
'status' => 'required|integer|in:1,3,4,9',
|
'status' => 'required|integer|in:1,9',
|
||||||
], [
|
], [
|
||||||
'ids.required' => '请选择要流转的订单',
|
'ids.required' => '请选择要流转的订单',
|
||||||
'ids.min' => '请选择要流转的订单',
|
'ids.min' => '请选择要流转的订单',
|
||||||
'ids.*.exists' => '订单不存在',
|
'ids.*.exists' => '订单不存在',
|
||||||
'status.required' => '目标状态不能为空',
|
'status.required' => '目标状态不能为空',
|
||||||
'status.in' => '目标状态值不正确',
|
'status.in' => '目标状态仅支持接单或取消订单',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$target = (int) $data['status'];
|
$target = (int) $data['status'];
|
||||||
@@ -196,6 +211,9 @@ class StoreOrderController extends BaseController
|
|||||||
/**
|
/**
|
||||||
* 状态流转合法路径
|
* 状态流转合法路径
|
||||||
*
|
*
|
||||||
|
* 手动流转仅保留「接单」「取消订单」;后续状态由业务链路自动推进:
|
||||||
|
* 生成采购单 → 采购中;采购单完成 → 配送中;生成账单 → 已完成
|
||||||
|
*
|
||||||
* @return int[]
|
* @return int[]
|
||||||
*/
|
*/
|
||||||
private function allowedTransitions(int $status): array
|
private function allowedTransitions(int $status): array
|
||||||
@@ -205,35 +223,48 @@ class StoreOrderController extends BaseController
|
|||||||
StoreOrderModel::STATUS_SUMMARIZED,
|
StoreOrderModel::STATUS_SUMMARIZED,
|
||||||
StoreOrderModel::STATUS_CANCELLED,
|
StoreOrderModel::STATUS_CANCELLED,
|
||||||
],
|
],
|
||||||
StoreOrderModel::STATUS_DELIVERING => [
|
|
||||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
|
||||||
StoreOrderModel::STATUS_COMPLETED,
|
|
||||||
],
|
|
||||||
StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED],
|
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态流转后通知门店用户
|
* 状态流转后通知门店
|
||||||
*/
|
*/
|
||||||
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([
|
'store_id' => $store->id,
|
||||||
'user_id' => $userId,
|
'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),
|
'data' => ['order_id' => $order->id],
|
||||||
'data' => ['order_id' => $order->id],
|
'is_read' => NoticeModel::UNREAD,
|
||||||
'is_read' => NoticeModel::UNREAD,
|
]);
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单输出数组的关联账单补充支付进度(待支付/审核中/已支付,与小程序端口径一致)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $order 订单数组(引用修改)
|
||||||
|
*/
|
||||||
|
private function appendBillPayState(array &$order): void
|
||||||
|
{
|
||||||
|
if (! is_array($order['bill'] ?? null)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 复用模型支付进度推导(已支付 > 审核中 > 待支付)
|
||||||
|
$bill = new BillModel($order['bill']);
|
||||||
|
$payState = $bill->payState();
|
||||||
|
$order['bill']['pay_state'] = $payState;
|
||||||
|
$order['bill']['pay_state_name'] = BillModel::PAY_STATE_NAMES[$payState];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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->delete();
|
||||||
$product->prices()->delete();
|
return $this->success();
|
||||||
$product->delete();
|
}
|
||||||
});
|
|
||||||
|
/** 批量删除商品(软删除),复用 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;
|
||||||
|
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||||||
// 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行)
|
|
||||||
if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) {
|
|
||||||
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)
|
->where('status', StoreModel::STATUS_NORMAL)
|
||||||
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
|
||||||
$q->select('id')
|
|
||||||
->from('store')
|
|
||||||
->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,7 +270,7 @@ class PurchaseOrderController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出采购单 Excel 表格
|
* 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选)
|
||||||
*
|
*
|
||||||
* @throws
|
* @throws
|
||||||
*/
|
*/
|
||||||
@@ -258,15 +281,57 @@ class PurchaseOrderController extends BaseController
|
|||||||
if (empty($purchase)) {
|
if (empty($purchase)) {
|
||||||
throw new RepositoryException('采购单不存在');
|
throw new RepositoryException('采购单不存在');
|
||||||
}
|
}
|
||||||
$type = (string) $request->query('type', 'all');
|
$supplierId = (int) $request->query('supplier_id', 0);
|
||||||
if (! in_array($type, ['all', 'category'], true)) {
|
|
||||||
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
|
$filename = $purchase->purchase_no . '_采购单.xlsx';
|
||||||
|
if ($supplierId > 0) {
|
||||||
|
$supplier = SupplierModel::withTrashed()->find($supplierId);
|
||||||
|
$filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Excel::download(
|
return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename);
|
||||||
new PurchaseOrderExport($purchase, $type),
|
}
|
||||||
$purchase->purchase_no . '_采购单.xlsx',
|
|
||||||
);
|
/**
|
||||||
|
* 导出门店购买详情(多工作表:每门店一个工作表;?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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,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([], '已移除单品');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单生成预览
|
* 账单生成预览
|
||||||
*/
|
*/
|
||||||
@@ -432,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()
|
||||||
@@ -451,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,
|
||||||
@@ -493,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]+'])]
|
||||||
@@ -503,31 +701,77 @@ 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();
|
||||||
|
|
||||||
$items = StoreOrderItemModel::query()
|
return DB::transaction(function () use ($purchase, $productId, $validated) {
|
||||||
->where('purchase_id', $purchase->id)
|
$items = StoreOrderItemModel::query()
|
||||||
->where('product_id', $productId)
|
->where('purchase_id', $purchase->id)
|
||||||
->lockForUpdate()
|
->where('product_id', $productId)
|
||||||
->get();
|
->lockForUpdate()
|
||||||
if ($items->isEmpty()) {
|
->get();
|
||||||
throw new RepositoryException('该采购单下无此商品的订货明细');
|
if ($items->isEmpty()) {
|
||||||
}
|
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||||
foreach ($items as $item) {
|
}
|
||||||
$item->fill($validated)->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重算采购单成本
|
// 成本价是否变化(未变化时保留明细单价,避免覆盖单元格/单品的手工改价)
|
||||||
$query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id);
|
$newCost = bcadd((string) $validated['cost_price'], '0', 2);
|
||||||
$purchase->total_quantity = $query->sum('quantity');
|
$costChanged = bccomp($newCost, bcadd((string) $items->first()->cost_price, '0', 2), 2) !== 0;
|
||||||
$purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price'));
|
|
||||||
$purchase->save();
|
|
||||||
|
|
||||||
return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细');
|
// 成本变化 → 按门店等级上浮比例重算单价(一次性取回涉及门店的等级,避免逐行查询)
|
||||||
|
$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) {
|
||||||
|
$item->fill($validated);
|
||||||
|
if ($costChanged) {
|
||||||
|
$item->price = CustomerLevelModel::calcLevelPrice(
|
||||||
|
$newCost,
|
||||||
|
$levelPercents[(int) $item->store_id] ?? 0,
|
||||||
|
);
|
||||||
|
$item->amount = bcmul($item->price, (string) $item->quantity, 2);
|
||||||
|
}
|
||||||
|
$item->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步保存到商品档案(软删除商品跳过,明细照常更新)
|
||||||
|
$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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -548,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('采购单已完成,不允许修改明细');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ namespace App\Http\Controllers\Recon;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Exports\BillExport;
|
use App\Exports\BillExport;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
use App\Services\BillDetailService;
|
use App\Services\BillDetailService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
@@ -119,7 +121,8 @@ class BillController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认收款:线下收款后手动登记付款信息,支付状态置为已支付
|
* 确认收款:线下收款后手动登记付款信息,支付状态置为已支付;
|
||||||
|
* 累加门店总采购金额(只统计商品金额)
|
||||||
*/
|
*/
|
||||||
#[PutRoute(route: '/{id}/pay', authorize: 'pay', where: ['id' => '[0-9]+'])]
|
#[PutRoute(route: '/{id}/pay', authorize: 'pay', where: ['id' => '[0-9]+'])]
|
||||||
public function pay(int $id, Request $request): JsonResponse
|
public function pay(int $id, Request $request): JsonResponse
|
||||||
@@ -132,20 +135,29 @@ class BillController extends BaseController
|
|||||||
'pay_remark.max' => '付款备注超过最大长度',
|
'pay_remark.max' => '付款备注超过最大长度',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$bill = BillModel::find($id);
|
return DB::transaction(function () use ($id, $data, $request) {
|
||||||
if (empty($bill)) {
|
$bill = BillModel::query()->lockForUpdate()->find($id);
|
||||||
throw new RepositoryException('账单不存在');
|
if (empty($bill)) {
|
||||||
}
|
throw new RepositoryException('账单不存在');
|
||||||
if ($bill->status === BillModel::STATUS_PAID) {
|
}
|
||||||
throw new RepositoryException('账单已支付,请勿重复收款');
|
if ($bill->status === BillModel::STATUS_PAID) {
|
||||||
}
|
throw new RepositoryException('账单已支付,请勿重复收款');
|
||||||
|
}
|
||||||
|
|
||||||
$bill->status = BillModel::STATUS_PAID;
|
$bill->status = BillModel::STATUS_PAID;
|
||||||
$bill->paid_at = $data['paid_at'] ?? now();
|
$bill->paid_at = $data['paid_at'] ?? now();
|
||||||
$bill->pay_remark = (string) ($data['pay_remark'] ?? '');
|
$bill->pay_remark = (string) ($data['pay_remark'] ?? '');
|
||||||
$bill->paid_operator_id = (int) $request->user()->id;
|
$bill->paid_operator_id = (int) $request->user()->id;
|
||||||
$bill->save();
|
$bill->save();
|
||||||
|
|
||||||
return $this->success([], '收款已登记');
|
// 累加门店总采购金额(只统计商品金额,不含配送费/附加金额)
|
||||||
|
$store = StoreModel::query()->lockForUpdate()->find($bill->store_id);
|
||||||
|
if ($store !== null) {
|
||||||
|
$store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, (string) $bill->product_amount, 2);
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([], '收款已登记');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,43 +3,43 @@
|
|||||||
namespace App\Http\Controllers\Recon;
|
namespace App\Http\Controllers\Recon;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Http\Requests\Recon\ContainerReturnFormRequest;
|
use App\Exports\ContainerReturnExport;
|
||||||
use App\Models\ContainerReturnModel;
|
use App\Models\ContainerReturnModel;
|
||||||
use App\Models\StoreModel;
|
use App\Models\StoreModel;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Carbon;
|
||||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
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\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
use Throwable;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记,扣减门店待回数量)
|
* 压回筐记录(周转筐/托盘跟账单走,生成账单时自动写入完整快照,只读)
|
||||||
|
*
|
||||||
|
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额)
|
||||||
*/
|
*/
|
||||||
#[RequestAttribute('/recon/container-return', 'recon.containerReturn')]
|
#[RequestAttribute('/recon/container-return', 'recon.containerReturn')]
|
||||||
class ContainerReturnController extends BaseController
|
class ContainerReturnController extends BaseController
|
||||||
{
|
{
|
||||||
protected array $searchField = [
|
protected array $searchField = [
|
||||||
'store_id' => '=',
|
'store_id' => '=',
|
||||||
'type' => '=',
|
'bill_id' => '=',
|
||||||
'return_date' => 'betweenDate',
|
'created_at' => 'betweenDate',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 回筐记录列表 */
|
/** 压回筐记录列表 */
|
||||||
#[GetRoute(authorize: 'query')]
|
#[GetRoute(authorize: 'query')]
|
||||||
public function query(Request $request): JsonResponse
|
public function query(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$params = $request->all();
|
$params = $request->all();
|
||||||
$pageSize = $params['pageSize'] ?? 10;
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
$data = $this->buildSearch($params, ContainerReturnModel::query()->with([
|
$data = $this->buildSearch($params, ContainerReturnModel::query()->with([
|
||||||
'store:id,name,pending_box_num,pending_tray_num',
|
'store:id,name',
|
||||||
'bill:id,bill_no',
|
'bill:id,bill_no,bill_date',
|
||||||
'operator:id,nickname',
|
'operator:id,nickname',
|
||||||
]))
|
]))
|
||||||
->orderBy('return_date', 'desc')
|
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize)
|
->paginate($pageSize)
|
||||||
->toArray();
|
->toArray();
|
||||||
@@ -47,79 +47,59 @@ class ContainerReturnController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回筐登记:门店退回周转筐/托盘,扣减门店待回数量(超回拒绝)
|
* 汇总导出:按日期区间 + 门店导出抵扣(附加)金额矩阵,
|
||||||
* @throws Throwable
|
* 行=日期(同日记录合并),列=门店,含行合计/列合计,当天门店无记录填 0
|
||||||
*/
|
*/
|
||||||
#[PostRoute(authorize: 'create')]
|
#[GetRoute(route: '/export', authorize: 'export')]
|
||||||
public function create(ContainerReturnFormRequest $request): JsonResponse
|
public function export(Request $request): Response
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validate([
|
||||||
$boxNum = (int) $data['box_num'];
|
'start_date' => 'required|date_format:Y-m-d',
|
||||||
$trayNum = (int) $data['tray_num'];
|
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||||
if ($boxNum === 0 && $trayNum === 0) {
|
'store_ids' => 'nullable|string',
|
||||||
throw new RepositoryException('周转筐与周转托盘数量不能同时为 0');
|
], [
|
||||||
|
'start_date.required' => '请选择开始日期',
|
||||||
|
'start_date.date_format' => '开始日期格式为 Y-m-d',
|
||||||
|
'end_date.required' => '请选择结束日期',
|
||||||
|
'end_date.date_format' => '结束日期格式为 Y-m-d',
|
||||||
|
'end_date.after_or_equal' => '结束日期不能早于开始日期',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$startDate = $data['start_date'];
|
||||||
|
$endDate = $data['end_date'];
|
||||||
|
if (Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)) > 366) {
|
||||||
|
throw new RepositoryException('日期区间不能超过 366 天');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($data, $boxNum, $trayNum, $request) {
|
$storeIds = array_values(array_filter(
|
||||||
$store = StoreModel::query()->lockForUpdate()->find((int) $data['store_id']);
|
array_map(intval(...), explode(',', (string) ($data['store_ids'] ?? '')))
|
||||||
if (empty($store)) {
|
));
|
||||||
throw new RepositoryException('门店不存在');
|
|
||||||
}
|
|
||||||
if ($boxNum > (int) $store->pending_box_num) {
|
|
||||||
throw new RepositoryException(
|
|
||||||
'回筐数量超过该门店待回筐数量(当前待回 ' . (int) $store->pending_box_num . ' 个)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($trayNum > (int) $store->pending_tray_num) {
|
|
||||||
throw new RepositoryException(
|
|
||||||
'回托盘数量超过该门店待回托盘数量(当前待回 ' . (int) $store->pending_tray_num . ' 个)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$store->pending_box_num = (int) $store->pending_box_num - $boxNum;
|
$records = ContainerReturnModel::query()
|
||||||
$store->pending_tray_num = (int) $store->pending_tray_num - $trayNum;
|
->whereBetween('created_at', [$startDate . ' 00:00:00', $endDate . ' 23:59:59'])
|
||||||
$store->save();
|
->when($storeIds !== [], static fn ($query) => $query->whereIn('store_id', $storeIds))
|
||||||
|
->get(['id', 'store_id', 'amount', 'created_at']);
|
||||||
|
|
||||||
ContainerReturnModel::create([
|
// 已选门店:列取所选门店(无记录填 0);未选门店:列取区间内有记录的门店
|
||||||
'store_id' => $store->id,
|
$columnStoreIds = $storeIds !== []
|
||||||
'bill_id' => 0,
|
? $storeIds
|
||||||
'type' => ContainerReturnModel::TYPE_RETURN,
|
: $records->pluck('store_id')->unique()->map(static fn ($id): int => (int) $id)->all();
|
||||||
'box_num' => $boxNum,
|
if ($columnStoreIds === []) {
|
||||||
'tray_num' => $trayNum,
|
throw new RepositoryException('所选日期区间内无压回筐记录');
|
||||||
'return_date' => $data['return_date'],
|
|
||||||
'operator_id' => (int) $request->user()->id,
|
|
||||||
'remark' => (string) ($data['remark'] ?? ''),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->success([], '回筐已登记');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除回筐记录(仅手动登记的回筐记录可删,删除后恢复门店待回数量;压筐记录随账单生成不允许删除)
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
|
||||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
|
||||||
public function delete(int $id): JsonResponse
|
|
||||||
{
|
|
||||||
$record = ContainerReturnModel::find($id);
|
|
||||||
if (empty($record)) {
|
|
||||||
throw new RepositoryException('回筐记录不存在');
|
|
||||||
}
|
}
|
||||||
if ($record->type !== ContainerReturnModel::TYPE_RETURN) {
|
sort($columnStoreIds);
|
||||||
throw new RepositoryException('压筐记录由账单生成,不允许删除');
|
|
||||||
|
$names = StoreModel::withTrashed()
|
||||||
|
->whereIn('id', $columnStoreIds)
|
||||||
|
->pluck('name', 'id');
|
||||||
|
$storeNames = [];
|
||||||
|
foreach ($columnStoreIds as $storeId) {
|
||||||
|
$storeNames[$storeId] = $names->get($storeId, '门店#' . $storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($record) {
|
return Excel::download(
|
||||||
$store = StoreModel::query()->lockForUpdate()->find($record->store_id);
|
new ContainerReturnExport($records, $storeNames, $startDate, $endDate),
|
||||||
if ($store !== null) {
|
'回筐记录_' . $startDate . '_' . $endDate . '.xlsx'
|
||||||
$store->pending_box_num = (int) $store->pending_box_num + (int) $record->box_num;
|
);
|
||||||
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $record->tray_num;
|
|
||||||
$store->save();
|
|
||||||
}
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
return $this->success([], '回筐记录已删除,门店待回数量已恢复');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Recon;
|
|||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
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;
|
||||||
@@ -23,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',
|
||||||
];
|
];
|
||||||
@@ -34,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')
|
||||||
@@ -47,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();
|
||||||
@@ -112,6 +114,19 @@ class PaymentController extends BaseController
|
|||||||
'paid_operator_id' => $auditorId,
|
'paid_operator_id' => $auditorId,
|
||||||
'pay_remark' => $methodName . '(支付单号 ' . $payment->payment_no . ')',
|
'pay_remark' => $methodName . '(支付单号 ' . $payment->payment_no . ')',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
|
||||||
|
foreach ($bills->groupBy('store_id')->sortKeys() as $storeId => $storeBills) {
|
||||||
|
$amount = '0';
|
||||||
|
foreach ($storeBills as $storeBill) {
|
||||||
|
$amount = bcadd($amount, (string) $storeBill->product_amount, 2);
|
||||||
|
}
|
||||||
|
$store = StoreModel::query()->lockForUpdate()->find((int) $storeId);
|
||||||
|
if ($store !== null) {
|
||||||
|
$store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, $amount, 2);
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
$payment->status = PaymentModel::STATUS_APPROVED;
|
$payment->status = PaymentModel::STATUS_APPROVED;
|
||||||
} else {
|
} else {
|
||||||
// 拒绝:释放账单,门店可重新发起付款
|
// 拒绝:释放账单,门店可重新发起付款
|
||||||
|
|||||||
@@ -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,7 +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
|
||||||
{
|
{
|
||||||
@@ -17,8 +18,10 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
|
|||||||
'stores' => 'required|array|min:1',
|
'stores' => 'required|array|min:1',
|
||||||
'stores.*.store_id' => 'required|integer|distinct',
|
'stores.*.store_id' => 'required|integer|distinct',
|
||||||
'stores.*.delivery_fee' => 'required|numeric|min:0',
|
'stores.*.delivery_fee' => 'required|numeric|min:0',
|
||||||
'stores.*.box_num' => 'required|integer|min:0',
|
'stores.*.box_num' => 'required|integer',
|
||||||
'stores.*.tray_num' => 'required|integer|min:0',
|
'stores.*.tray_num' => 'required|integer',
|
||||||
|
'stores.*.after_sale' => 'nullable|numeric',
|
||||||
|
'stores.*.remark' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +38,11 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
|
|||||||
'stores.*.delivery_fee.numeric' => '配送费格式错误',
|
'stores.*.delivery_fee.numeric' => '配送费格式错误',
|
||||||
'stores.*.delivery_fee.min' => '配送费不能小于 0',
|
'stores.*.delivery_fee.min' => '配送费不能小于 0',
|
||||||
'stores.*.box_num.required' => '周转筐数量不能为空',
|
'stores.*.box_num.required' => '周转筐数量不能为空',
|
||||||
'stores.*.box_num.integer' => '周转筐数量必须为整数',
|
'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
|
||||||
'stores.*.box_num.min' => '周转筐数量不能小于 0',
|
|
||||||
'stores.*.tray_num.required' => '周转托盘数量不能为空',
|
'stores.*.tray_num.required' => '周转托盘数量不能为空',
|
||||||
'stores.*.tray_num.integer' => '周转托盘数量必须为整数',
|
'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
|
||||||
'stores.*.tray_num.min' => '周转托盘数量不能小于 0',
|
'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,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Recon;
|
|
||||||
|
|
||||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回筐登记 验证(门店退回周转筐/托盘,扣减门店待回数量)
|
|
||||||
*/
|
|
||||||
class ContainerReturnFormRequest extends BaseFormRequest
|
|
||||||
{
|
|
||||||
protected $stopOnFirstFailure = true;
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'store_id' => 'required|integer|exists:store,id',
|
|
||||||
'box_num' => 'required|integer|min:0',
|
|
||||||
'tray_num' => 'required|integer|min:0',
|
|
||||||
'return_date' => 'required|date_format:Y-m-d',
|
|
||||||
'remark' => 'nullable|string|max:255',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'store_id.required' => '请选择门店',
|
|
||||||
'store_id.exists' => '门店不存在',
|
|
||||||
'box_num.required' => '周转筐数量不能为空',
|
|
||||||
'box_num.integer' => '周转筐数量必须为整数',
|
|
||||||
'box_num.min' => '周转筐数量不能小于 0',
|
|
||||||
'tray_num.required' => '周转托盘数量不能为空',
|
|
||||||
'tray_num.integer' => '周转托盘数量必须为整数',
|
|
||||||
'tray_num.min' => '周转托盘数量不能小于 0',
|
|
||||||
'return_date.required' => '请选择退回日期',
|
|
||||||
'return_date.date_format' => '退回日期格式为 Y-m-d',
|
|
||||||
'remark.max' => '备注超过最大长度',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,44 +8,36 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Modules\SystemUser\Models\SysUserModel;
|
use Modules\SystemUser\Models\SysUserModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店回筐记录模型(压筐=生成账单时自动写入并累加门店待回;回筐=门店退回手动登记并扣减待回)
|
* 门店压回筐记录模型(周转筐/托盘跟账单走,生成账单时写入完整快照)
|
||||||
|
*
|
||||||
|
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额);数量为 0 不写记录
|
||||||
*/
|
*/
|
||||||
class ContainerReturnModel extends Model
|
class ContainerReturnModel extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/** 类型:压筐(账单生成压出,系统写入只读) */
|
|
||||||
public const int TYPE_PRESS = 1;
|
|
||||||
/** 类型:回筐(门店退回,后台手动登记) */
|
|
||||||
public const int TYPE_RETURN = 2;
|
|
||||||
|
|
||||||
/** 类型中文名 */
|
|
||||||
public const array TYPE_NAMES = [
|
|
||||||
self::TYPE_PRESS => '压筐',
|
|
||||||
self::TYPE_RETURN => '回筐',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $table = 'container_return';
|
protected $table = 'container_return';
|
||||||
protected $primaryKey = 'id';
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'store_id',
|
'store_id',
|
||||||
'bill_id',
|
'bill_id',
|
||||||
'type',
|
|
||||||
'box_num',
|
'box_num',
|
||||||
'tray_num',
|
'tray_num',
|
||||||
'return_date',
|
'box_price',
|
||||||
|
'tray_price',
|
||||||
|
'amount',
|
||||||
'operator_id',
|
'operator_id',
|
||||||
'remark',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'store_id' => 'integer',
|
'store_id' => 'integer',
|
||||||
'bill_id' => 'integer',
|
'bill_id' => 'integer',
|
||||||
'type' => 'integer',
|
|
||||||
'box_num' => 'integer',
|
'box_num' => 'integer',
|
||||||
'tray_num' => 'integer',
|
'tray_num' => 'integer',
|
||||||
'return_date' => 'date:Y-m-d',
|
'box_price' => 'decimal:2',
|
||||||
|
'tray_price' => 'decimal:2',
|
||||||
|
'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',
|
||||||
];
|
];
|
||||||
@@ -59,7 +51,7 @@ class ContainerReturnModel extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关联账单(压筐记录)
|
* 关联账单
|
||||||
*/
|
*/
|
||||||
public function bill(): BelongsTo
|
public function bill(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-15
@@ -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(逗号分隔字符串 ↔ 数组)
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +139,10 @@ class PaymentModel extends Model
|
|||||||
if ($ids === []) {
|
if ($ids === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$urls = SysFileModel::query()->whereIn('id', $ids)->pluck('preview_url', 'id');
|
$urls = [];
|
||||||
|
foreach (SysFileModel::query()->whereIn('id', $ids)->get() as $file) {
|
||||||
|
$urls[$file->id] = $file->preview_url;
|
||||||
|
}
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
if (isset($urls[$id])) {
|
if (isset($urls[$id])) {
|
||||||
@@ -121,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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-14
@@ -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,21 +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',
|
||||||
'pending_box_num' => 'integer',
|
'total_purchase_amount' => 'decimal:2',
|
||||||
'pending_tray_num' => 'integer',
|
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
|
'last_login_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,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');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店账单(采购单完成后按门店生成)
|
* 门店账单(采购单完成后按门店生成)
|
||||||
*/
|
*/
|
||||||
@@ -74,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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class BillDetailService
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 合并后的商品明细:按商品聚合账单关联的全部订单明细
|
* 合并后的商品明细:按商品聚合账单关联的全部订单明细
|
||||||
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=金额
|
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=金额;附商品首图
|
||||||
*
|
*
|
||||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, image: string}>
|
||||||
*/
|
*/
|
||||||
public function mergedItems(BillModel $bill): array
|
public function mergedItems(BillModel $bill): array
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@ class BillDetailService
|
|||||||
* 用于账单合并导出,单价同为加权平均口径
|
* 用于账单合并导出,单价同为加权平均口径
|
||||||
*
|
*
|
||||||
* @param array<int, int> $billIds 账单ID列表
|
* @param array<int, int> $billIds 账单ID列表
|
||||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, image: string}>
|
||||||
*/
|
*/
|
||||||
public function mergedItemsOfBills(array $billIds): array
|
public function mergedItemsOfBills(array $billIds): array
|
||||||
{
|
{
|
||||||
@@ -47,10 +47,10 @@ class BillDetailService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按商品聚合订单明细:数量累加、重量/金额 bc 累加、单价加权平均,
|
* 按商品聚合订单明细:数量累加、重量/金额 bc 累加、单价加权平均,
|
||||||
* 按「分类sort → 商品sort」排序
|
* 首图取明细快照 image_ids 批量解析,按「分类sort → 商品sort」排序
|
||||||
*
|
*
|
||||||
* @param Collection<int, StoreOrderItemModel> $items
|
* @param Collection<int, StoreOrderItemModel> $items
|
||||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, image: string}>
|
||||||
*/
|
*/
|
||||||
private function aggregateItems(Collection $items): array
|
private function aggregateItems(Collection $items): array
|
||||||
{
|
{
|
||||||
@@ -85,6 +85,9 @@ 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,
|
||||||
'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),
|
||||||
];
|
];
|
||||||
@@ -93,6 +96,9 @@ class BillDetailService
|
|||||||
[$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']]);
|
||||||
|
|
||||||
|
// 首图批量解析(写入 image、移除 image_ids)
|
||||||
|
app(ItemImageResolver::class)->resolve($rows);
|
||||||
|
|
||||||
return array_map(static function (array $row): array {
|
return array_map(static function (array $row): array {
|
||||||
unset($row['category_sort'], $row['product_sort']);
|
unset($row['category_sort'], $row['product_sort']);
|
||||||
return $row;
|
return $row;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -18,8 +19,12 @@ use Throwable;
|
|||||||
* 流程(事务内):
|
* 流程(事务内):
|
||||||
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
|
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
|
||||||
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
||||||
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照)
|
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
|
||||||
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联
|
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
|
||||||
|
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
|
||||||
|
* 可正负:正数=加收,负数=售后减免)
|
||||||
|
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
|
||||||
|
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
|
||||||
*/
|
*/
|
||||||
readonly class BillGenerateService
|
readonly class BillGenerateService
|
||||||
{
|
{
|
||||||
@@ -29,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
|
||||||
@@ -79,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];
|
||||||
@@ -93,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'),
|
||||||
@@ -109,32 +127,34 @@ 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'] ?? ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 关联该门店在采购单中的全部订单与订单明细到账单
|
// 关联该门店在采购单中的全部订单与订单明细到账单,订单转入「已完成」
|
||||||
StoreOrderModel::query()
|
StoreOrderModel::query()
|
||||||
->whereIn('id', $storeOrders->pluck('id'))
|
->whereIn('id', $storeOrders->pluck('id'))
|
||||||
->update(['bill_id' => $bill->id]);
|
->update([
|
||||||
|
'bill_id' => $bill->id,
|
||||||
|
'status' => StoreOrderModel::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
StoreOrderItemModel::query()
|
StoreOrderItemModel::query()
|
||||||
->whereIn('order_id', $storeOrders->pluck('id'))
|
->whereIn('order_id', $storeOrders->pluck('id'))
|
||||||
->update(['bill_id' => $bill->id]);
|
->update(['bill_id' => $bill->id]);
|
||||||
|
|
||||||
// 压筐:累加门店待回筐/托盘(行锁防并发),并写入压筐记录
|
// 压回筐记录:数量为 0 不写记录;正数=压筐附加金额,负数=回筐抵扣金额
|
||||||
$store = StoreModel::query()->lockForUpdate()->find((int) $storeId);
|
if ((int) $row['box_num'] !== 0 || (int) $row['tray_num'] !== 0) {
|
||||||
if ($store !== null) {
|
ContainerReturnModel::create([
|
||||||
$store->pending_box_num = (int) $store->pending_box_num + (int) $row['box_num'];
|
'store_id' => (int) $storeId,
|
||||||
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $row['tray_num'];
|
'bill_id' => $bill->id,
|
||||||
$store->save();
|
'box_num' => (int) $row['box_num'],
|
||||||
|
'tray_num' => (int) $row['tray_num'],
|
||||||
|
'box_price' => bcadd($boxPrice, '0', 2),
|
||||||
|
'tray_price' => bcadd($trayPrice, '0', 2),
|
||||||
|
'amount' => $addedAmount,
|
||||||
|
'operator_id' => $operatorId,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
ContainerReturnModel::create([
|
|
||||||
'store_id' => (int) $storeId,
|
|
||||||
'bill_id' => $bill->id,
|
|
||||||
'type' => ContainerReturnModel::TYPE_PRESS,
|
|
||||||
'box_num' => (int) $row['box_num'],
|
|
||||||
'tray_num' => (int) $row['tray_num'],
|
|
||||||
'return_date' => $billDate,
|
|
||||||
'operator_id' => $operatorId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$bills[] = $bill;
|
$bills[] = $bill;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +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->integer('pending_box_num')->default(0)->comment('待回筐数量(生成账单压筐累加,回筐登记扣减)');
|
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
|
||||||
$table->integer('pending_tray_num')->default(0)->comment('待回托盘数量');
|
$table->string('mp_openid', 64)->default('')->comment('微信公众号 openid(公众号 JSAPI 支付付款人标识,网页授权换取后绑定)');
|
||||||
|
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->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('门店表(门店即用户,账号密码登录)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,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('内容');
|
||||||
@@ -100,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('消息通知表');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,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');
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run the migrations.
|
* Run the migrations.
|
||||||
* 门店回筐台账:压筐(生成账单时自动写入)+ 回筐(门店退回,后台手动登记)
|
* 门店压回筐记录:周转筐/托盘跟账单走,生成账单时写入完整快照;
|
||||||
|
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不写记录
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
@@ -16,17 +17,17 @@ return new class extends Migration
|
|||||||
Schema::create('container_return', function (Blueprint $table) {
|
Schema::create('container_return', function (Blueprint $table) {
|
||||||
$table->increments('id')->comment('记录ID');
|
$table->increments('id')->comment('记录ID');
|
||||||
$table->integer('store_id')->comment('门店ID');
|
$table->integer('store_id')->comment('门店ID');
|
||||||
$table->integer('bill_id')->default(0)->comment('关联账单ID(0=手动回筐登记)');
|
$table->integer('bill_id')->comment('关联账单ID');
|
||||||
$table->integer('type')->comment('类型(1压筐-账单生成 2回筐-退回登记)');
|
$table->integer('box_num')->default(0)->comment('压(回)筐数量(正数=压筐,负数=回筐)');
|
||||||
$table->integer('box_num')->default(0)->comment('周转筐数量');
|
$table->integer('tray_num')->default(0)->comment('压(回)托盘数量(正数=压筐,负数=回筐)');
|
||||||
$table->integer('tray_num')->default(0)->comment('周转托盘数量');
|
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成账单时快照)');
|
||||||
$table->date('return_date')->comment('记录日期(压筐=账单日期,回筐=退回日期)');
|
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成账单时快照)');
|
||||||
|
$table->decimal('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->timestamps();
|
$table->timestamps();
|
||||||
$table->index(['store_id', 'type'], 'container_return_store_type_index');
|
$table->index(['store_id'], 'container_return_store_index');
|
||||||
$table->index(['bill_id'], 'container_return_bill_index');
|
$table->index(['bill_id'], 'container_return_bill_index');
|
||||||
$table->comment('门店回筐记录表(压筐=生成账单压出,回筐=门店退回登记)');
|
$table->comment('门店压回筐记录表(随账单生成:正数=压筐附加金额,负数=回筐抵扣金额)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,16 +52,6 @@ return new class extends Migration
|
|||||||
$table->comment('小程序首页促销推荐卡片');
|
$table->comment('小程序首页促销推荐卡片');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件分组:客户端配置图片(幂等,已有库补充分组记录)
|
|
||||||
DB::table('sys_file_group')->insertOrIgnore([
|
|
||||||
'id' => 12,
|
|
||||||
'name' => '客户端配置',
|
|
||||||
'sort' => 11,
|
|
||||||
'describe' => '小程序首页轮播图/宫格导航/促销卡片图片',
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user